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
@@ -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
|
||||
Reference in new issue
Block a user