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