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

+50
View File
@@ -416,3 +416,53 @@ async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client
rounds = (await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})).json()
assert rounds[0]["server_seed_hash"] == "ab" * 32 and rounds[0]["draw_height"] == 77
assert "server_seed" not in rounds[0] and "cd" * 32 not in str(rounds[0])
# --- B-79 (RND-40, API-31): a bet amount or fee rate that would make a round unpayable ---
@pytest.mark.parametrize(
"payload",
[
{"bet_amount_sats": 5_000}, # the platform's 30% cannot cover the reserve and dust
{"bet_amount_sats": 3_000}, # does not even pay for its own transaction
],
)
async def test_config_refuses_a_change_that_would_make_a_round_unpayable(client, payload):
headers = {"X-Admin-Token": "test-admin-token"}
before = (await client.get("/admin/config", headers=headers)).json()
resp = await client.put("/admin/config", headers=headers, json=payload)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert detail["code"] == "config_not_payable" and detail["params"]["reason"]
assert (await client.get("/admin/config", headers=headers)).json() == before # nothing written
audit = (await client.get("/admin/audit-log", headers=headers)).json()
assert not [e for e in audit if e["event_type"] == "config_updated"]
async def test_a_fee_rate_raised_mid_round_cannot_make_the_round_unpayable(client):
"""The payout reads fee_rate_sat_vb when it is built, so the rate is the one live value
that can still break a round in progress — it is bounded by the same check, judged
together with the stored bet amount (and with a bet amount sent in the same request)."""
headers = {"X-Admin-Token": "test-admin-token"}
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
assert (await put({"bet_amount_sats": 50_000})).status_code == 200
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200 # still payable: 1 bet nets 15_530
resp = await put({"fee_rate_sat_vb": 14}) # the 70% no longer covers the payout's network fee
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
assert (await client.get("/admin/config", headers=headers)).json()["fee_rate_sat_vb"] == 10
# merged with the same request: a larger bet makes the higher rate payable
resp = await put({"bet_amount_sats": 1_000_000, "fee_rate_sat_vb": 20})
assert resp.status_code == 200
# ... and the other way round, a bet that is fine alone is refused at the stored rate
assert (await put({"bet_amount_sats": 50_000})).status_code == 422
async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.put(
"/admin/config", headers=headers, json={"bet_amount_sats": 100_000, "fee_rate_sat_vb": 2, "rbf_timeout_seconds": 120}
)
assert resp.status_code == 200
# an unrelated edit is never blocked by the payability check
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
+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)
+7
View File
@@ -196,3 +196,10 @@ def test_two_participants_cannot_share_a_draw_position_but_null_positions_coexis
insert(3, 0)
with pytest.raises(sqlite3.IntegrityError):
insert(4, 0)
def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): # B-79 (DAT-73)
with sqlite3.connect(migrated_db) as conn:
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
+133 -12
View File
@@ -5,6 +5,7 @@ from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_PAYOUT_TX_INPUTS,
MAX_TX_INPUTS,
@@ -12,6 +13,7 @@ from app.wallet.psbt_builder import (
Utxo,
build_payout_transaction,
estimate_vsize,
payout_bump_reserve,
)
@@ -40,23 +42,35 @@ def test_payout_deducts_fee_only_from_winner_share():
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
fee = estimate_vsize(1, 3)
reserve = payout_bump_reserve(1) # B-79 (RND-38): 172 vB * 10 sat/vB
assert reserve == 1_720
assert built.fee_sats == fee
assert built.winner_sats == winner_share - fee
assert built.commission_sats == commission # untouched by the fee
assert built.change_sats == pool_amount - (winner_share + commission)
assert built.reserve_sats == reserve
assert built.commission_sats == commission - reserve # the reserve comes out of the platform's share
assert built.change_sats == 0
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 2 # no change needed: winner_share + commission == pool_amount exactly
amounts = sorted(o.value for o in parsed.vout)
assert amounts == sorted([built.winner_sats, built.commission_sats])
assert len(parsed.vout) == 3 # winner, fee address, reserve — always, B-79
assert [o.value for o in parsed.vout] == [built.winner_sats, built.commission_sats, built.reserve_sats]
assert [o.script_pubkey.address(network=PLM_MAINNET) for o in parsed.vout] == [
winner_address,
fee_address,
pool_address, # stands in for the reserve address here
]
# nothing is created or lost: the inputs are exactly the outputs plus the network fee
assert sum(o.value for o in parsed.vout) + built.fee_sats == pool_amount
def test_payout_adds_change_output_when_pool_utxos_exceed_target():
def test_payout_folds_excess_input_value_into_the_reserve_output():
"""Only a round opened before the per-round pool address can have inputs beyond winner
share + commission (the shared pool held other money). The excess goes to the reserve
address, in the same single reserve output (RND-23), not into a fourth output."""
pool_key = _key(20)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
@@ -74,13 +88,40 @@ def test_payout_adds_change_output_when_pool_utxos_exceed_target():
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 2_000_000_000 - (winner_share + commission)
excess = 2_000_000_000 - (winner_share + commission)
assert built.change_sats == excess
assert built.reserve_sats == payout_bump_reserve(1) + excess
assert built.commission_sats == commission - payout_bump_reserve(1)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 3
assert sum(o.value for o in parsed.vout) + built.fee_sats == 2_000_000_000
def test_payout_leaves_a_dust_sized_excess_to_the_network_fee():
pool_key = _key(23)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(24).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(25).to_public()).address(network=PLM_MAINNET)
excess = DUST_LIMIT_SATS - 1
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=[Utxo("bc" * 32, 0, 1_000_000_000 + excess)],
winner_address=winner_address,
winner_share_sats=700_000_000,
fee_address=fee_address,
commission_sats=300_000_000,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 0
assert built.reserve_sats == payout_bump_reserve(1)
assert built.fee_sats == estimate_vsize(1, 3) + excess
def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
@@ -111,7 +152,7 @@ def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
@@ -120,7 +161,8 @@ def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
assert len(parsed.vin) == participants
assert built.fee_sats == estimate_vsize(participants, 3)
assert built.winner_sats == winner_share - built.fee_sats
assert built.commission_sats == commission # still untouched by the fee
assert built.reserve_sats == payout_bump_reserve(participants)
assert built.commission_sats == commission - built.reserve_sats # still untouched by the network fee
def test_payout_at_the_participant_cap_stays_well_inside_relay_limits(): # B-52
@@ -152,6 +194,85 @@ def test_payout_raises_when_winner_share_too_small():
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
def _addresses(seed: int):
pool_key = _key(seed)
pool_script = script.p2wpkh(pool_key.to_public())
return (
pool_key,
pool_script,
script.p2wpkh(_key(seed + 1).to_public()).address(network=PLM_MAINNET),
script.p2wpkh(_key(seed + 2).to_public()).address(network=PLM_MAINNET),
script.p2wpkh(_key(seed + 3).to_public()).address(network=PLM_MAINNET),
)
def test_the_reserve_is_sized_on_the_inputs_and_never_below_dust(): # B-79, RND-38
assert payout_bump_reserve(1) == estimate_vsize(1, 3) * 10
assert payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND) == estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) * 10
assert payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND) > payout_bump_reserve(1)
assert payout_bump_reserve(0) == max(DUST_LIMIT_SATS, estimate_vsize(0, 3) * 10)
def test_payout_at_the_participant_cap_pays_the_reserve_out_of_the_platform_share(): # B-79, RND-38, RND-40
"""400 bets of the smallest net amount the default config allows: the inputs are exactly
the outputs plus the network fee, the winner's share carries the network fee, and the
platform's 30% carries the (much larger) reserve."""
pool_key, pool_script, winner_address, fee_address, reserve_address = _addresses(60)
bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2)
utxos = [Utxo(f"{i:064x}", 0, bet) for i in range(MAX_PARTICIPANTS_PER_ROUND)]
pool = bet * MAX_PARTICIPANTS_PER_ROUND
winner_share = pool * 70 // 100
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=pool - winner_share,
reserve_address=reserve_address,
fee_rate_sat_vb=1,
)
n = MAX_PARTICIPANTS_PER_ROUND
assert built.reserve_sats == estimate_vsize(n, 3) * 10 == 273_040
assert built.fee_sats == estimate_vsize(n, 3)
assert built.winner_sats == winner_share - built.fee_sats
assert built.commission_sats == pool - winner_share - built.reserve_sats
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vin) == n and len(parsed.vout) == 3
assert sum(o.value for o in parsed.vout) + built.fee_sats == pool
assert all(o.value >= DUST_LIMIT_SATS for o in parsed.vout)
def test_payout_with_one_participant_at_the_smallest_viable_pool(): # B-79, RND-40
"""The edge RND-40 guards: with one bet the platform's share is the smallest, and it has
to cover the reserve and still leave the fee address at least dust. The exact boundary
builds; one satoshi less is refused with commission_below_dust_limit."""
pool_key, pool_script, winner_address, fee_address, reserve_address = _addresses(70)
reserve = payout_bump_reserve(1)
commission = reserve + DUST_LIMIT_SATS # the fee address gets exactly the dust limit
winner_share = 10_000
def build(commission_sats: int):
return build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=[Utxo("ad" * 32, 0, winner_share + commission_sats)],
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission_sats,
reserve_address=reserve_address,
fee_rate_sat_vb=1,
)
built = build(commission)
assert built.commission_sats == DUST_LIMIT_SATS and built.reserve_sats == reserve
with pytest.raises(InsufficientFundsError) as refused:
build(commission - 1)
assert refused.value.code == "commission_below_dust_limit"
+20
View File
@@ -296,3 +296,23 @@ async def test_current_round_exposes_its_own_pool_address(client):
resp = await ac.get("/rounds/current")
assert resp.json()["pool_address"] == derive_pool_address(5)
async def test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_round(client): # B-79 (RND-40)
from sqlalchemy import select
from app.db.models import RoundConfig
ac, session_factory = client
async with session_factory() as session:
session.add(
RoundConfig(fee_address="plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n", bet_amount_sats=5_000)
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["lottery_configured"] is False and body["round_id"] is None
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000
await session.commit()
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
+82 -3
View File
@@ -236,9 +236,88 @@ async def test_a_round_in_progress_survives_the_fee_address_being_cleared(sessio
def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66
from app.rounds.service import rounds_can_open
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True
assert rounds_can_open(RoundConfig(fee_address="")) is False
assert rounds_can_open(RoundConfig(fee_address=" ")) is False
# B-79: the payability check also reads the amounts, which are column defaults only once
# a row is flushed — so a bare object spells them out.
amounts = {"bet_amount_sats": 1_000_000_000, "fee_rate_sat_vb": 1}
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS, **amounts)) is True
assert rounds_can_open(RoundConfig(fee_address="", **amounts)) is False
assert rounds_can_open(RoundConfig(fee_address=" ", **amounts)) is False
# --- B-79 (RND-40): a configuration under which a round cannot be paid opens no round ---
def test_payout_config_problem_at_both_extremes():
"""The default config is payable; the boundary bet amounts are found by the same formula
as the spec: net = bet - fee(50 inputs, 2 outputs) at the rate, checked for 1 participant
and for MAX_PARTICIPANTS_PER_ROUND."""
from app.rounds.service import payout_config_problem, winner_share
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
assert payout_config_problem(1_000_000_000, 1) is None
def feasible(bet: int, rate: int, n: int) -> bool:
pool = n * (bet - estimate_vsize(MAX_TX_INPUTS, 2) * rate)
share = winner_share(pool)
return (
share - estimate_vsize(n, 3) * rate >= DUST_LIMIT_SATS
and pool - share - payout_bump_reserve(n) >= DUST_LIMIT_SATS
)
# binary-search the smallest viable bet at 1 sat/vB for each extreme, independently of
# the function under test, then check it is exactly where payout_config_problem flips
def smallest(n: int, rate: int) -> int:
lo, hi = 1, 10_000_000
while lo < hi:
mid = (lo + hi) // 2
lo, hi = (lo, mid) if feasible(mid, rate, n) else (mid + 1, hi)
return lo
for rate in (1, 10):
edge = max(smallest(1, rate), smallest(MAX_PARTICIPANTS_PER_ROUND, rate))
assert payout_config_problem(edge, rate) is None
assert payout_config_problem(edge - 1, rate) is not None
# each extreme can be the one that fails: one participant is bounded by the platform share
# (reserve + dust), the maximum by the reserve that grows with the inputs
assert smallest(1, 1) != smallest(MAX_PARTICIPANTS_PER_ROUND, 1)
assert payout_config_problem(1, 1) is not None # a bet that does not even pay its own fee
async def test_no_round_opens_while_the_config_could_not_pay_one(session_factory):
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.bet_amount_sats = 5_000 # fee address set, but the platform's 30% cannot cover the reserve
await session.commit()
async with session_factory() as session:
assert await open_new_round_if_needed(session) is None
async with session_factory() as session:
assert (await session.scalars(select(Round))).all() == []
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
assert round_ is not None and round_.status == "open"
async def test_a_round_in_progress_survives_an_unpayable_config(session_factory):
"""Same scope as every other prerequisite: it only stops the *next* round."""
async with session_factory() as session:
session.add(Round(status="open"))
(await session.scalars(select(RoundConfig))).one().fee_rate_sat_vb = 5_000
await session.commit()
async with session_factory() as session:
returned = await open_new_round_if_needed(session)
assert returned is not None and returned.status == "open"
# --- B-61: a round runs by the timing it opened with, not by the live config ------
+34
View File
@@ -165,6 +165,40 @@ async def test_trigger_payout_persists_before_broadcasting(payout_session_factor
assert "payout_sent" in events
async def test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address(payout_session_factory):
"""B-79 (RND-23, RND-24, RND-38, DAT-22, DAT-73): the pool holds exactly the round's bets
and the payout spends them all, yet the transaction has three outputs — winner, fee
address, reserve at m/84'/746'/0'/1/0 — and the round records the reserve before the
broadcast. Nothing is created or lost to the satoshi."""
from app.wallet.psbt_builder import estimate_vsize, payout_bump_reserve
await _seed_paying_out_round(payout_session_factory)
entries = [{"tx_hash": "34" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}] # no excess
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
async with payout_session_factory() as session:
round_ = await session.get(Round, 1)
pending = (await session.scalars(select(PendingTransaction))).one()
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
from app.wallet.plm_network import PLM_MAINNET
addresses = [o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout]
assert addresses == [_WINNER_ADDRESS, _FEE_ADDRESS, derive_pool_address()] # reserve = 1/0
reserve = payout_bump_reserve(1)
network_fee = estimate_vsize(1, 3)
winner_share = _POOL_AMOUNT_SATS * 70 // 100
assert round_.bump_reserve_sats == reserve == tx.vout[2].value
assert round_.winner_amount_sats == winner_share - network_fee == tx.vout[0].value
assert round_.fee_amount_sats == _POOL_AMOUNT_SATS - winner_share - reserve == tx.vout[1].value
assert (
round_.winner_amount_sats + round_.fee_amount_sats + round_.bump_reserve_sats + network_fee
== _POOL_AMOUNT_SATS
)
async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs(
payout_session_factory,
): # B-52