The payout has to spend one pool UTXO per bet, so reusing MAX_TX_INPUTS (50) for it made any round past ~50 players unpayable: select_utxos raised too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and since no new round may open while one is active, the whole lottery stopped with the pool stuck. The cap was being enforced on the payout side, i.e. discovered once the money was already committed and there was no way back. Two halves: - select_utxos takes the cap as a parameter. Bets and withdrawals keep MAX_TX_INPUTS = 50, which protects a user from a fee that eats into the amount they are moving; the payout uses MAX_PAYOUT_TX_INPUTS = 500, where that argument doesn't apply — 400 inputs at 1 sat/vB cost ~0.00027 PLM out of the winner's 70% share. What actually bounds it is relay policy: 500 inputs is ~34 kvB against the 100 kvB standardness limit, and signing that many measures ~0.4s, once per round, inside a background task. - place_bet refuses the 401st bet with a new round_full error (translated into all 7 languages), so "a round can always be paid out" is an invariant checked before any money moves. MAX_PARTICIPANTS_PER_ROUND sits below the input cap to leave the payout headroom for pool change from earlier rounds, and counts every participant row rather than only confirmed ones, since a failed bet frees a slot. A round already wedged past the old cap now pays out on the next retry tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
8.5 KiB
Python
232 lines
8.5 KiB
Python
import pytest
|
|
from embit import script
|
|
from embit.bip32 import HDKey
|
|
from embit.transaction import Transaction
|
|
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
from app.wallet.psbt_builder import (
|
|
MAX_PAYOUT_TX_INPUTS,
|
|
MAX_TX_INPUTS,
|
|
InsufficientFundsError,
|
|
Utxo,
|
|
build_signed_transaction,
|
|
estimate_vsize,
|
|
select_utxos,
|
|
)
|
|
|
|
|
|
def _key(seed_byte: int) -> HDKey:
|
|
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
|
|
return root.derive("m/84h/746h/0h/0/0")
|
|
|
|
|
|
def test_estimate_vsize_grows_with_inputs_and_outputs():
|
|
assert estimate_vsize(1, 2) < estimate_vsize(2, 2)
|
|
assert estimate_vsize(1, 1) < estimate_vsize(1, 2)
|
|
|
|
|
|
def test_select_utxos_picks_largest_first():
|
|
utxos = [Utxo("a" * 64, 0, 5_000_000), Utxo("b" * 64, 0, 20_000_000), Utxo("c" * 64, 0, 1_000_000)]
|
|
selected, total = select_utxos(utxos, target_sats=10_000_000)
|
|
assert selected == [utxos[1]] # the 20M UTXO alone covers 10M
|
|
assert total == 20_000_000
|
|
|
|
|
|
def test_select_utxos_raises_when_insufficient():
|
|
utxos = [Utxo("a" * 64, 0, 1_000_000)]
|
|
with pytest.raises(InsufficientFundsError):
|
|
select_utxos(utxos, target_sats=10_000_000)
|
|
|
|
|
|
def test_select_utxos_never_exceeds_the_input_cap(): # B-48
|
|
# 200 dust-ish UTXOs that together cover the target, but only past the cap.
|
|
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
|
|
with pytest.raises(InsufficientFundsError) as excinfo:
|
|
select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS + 1)
|
|
assert excinfo.value.code == "too_many_inputs"
|
|
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
|
|
|
|
|
|
def test_select_utxos_allows_exactly_the_input_cap():
|
|
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
|
|
selected, total = select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS)
|
|
assert len(selected) == MAX_TX_INPUTS
|
|
assert total == 100_000 * MAX_TX_INPUTS
|
|
|
|
|
|
def test_select_utxos_honours_a_caller_supplied_cap(): # B-52
|
|
"""The cap is per-caller: MAX_TX_INPUTS protects a user from a fee eating into
|
|
their own bet/withdrawal, while the payout needs MAX_PAYOUT_TX_INPUTS to be able
|
|
to drain a pool holding one UTXO per bet at all."""
|
|
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_TX_INPUTS + 10)]
|
|
target = 100_000 * (MAX_TX_INPUTS + 10)
|
|
|
|
with pytest.raises(InsufficientFundsError):
|
|
select_utxos(utxos, target_sats=target) # default cap: too fragmented
|
|
|
|
selected, total = select_utxos(utxos, target_sats=target, max_inputs=MAX_PAYOUT_TX_INPUTS)
|
|
assert len(selected) == MAX_TX_INPUTS + 10
|
|
assert total == target
|
|
|
|
|
|
def test_select_utxos_still_caps_at_the_payout_limit(): # B-52
|
|
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_PAYOUT_TX_INPUTS + 5)]
|
|
with pytest.raises(InsufficientFundsError) as excinfo:
|
|
select_utxos(
|
|
utxos,
|
|
target_sats=100_000 * (MAX_PAYOUT_TX_INPUTS + 1),
|
|
max_inputs=MAX_PAYOUT_TX_INPUTS,
|
|
)
|
|
assert excinfo.value.code == "too_many_inputs"
|
|
assert excinfo.value.params == {"max_inputs": MAX_PAYOUT_TX_INPUTS}
|
|
|
|
|
|
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
|
|
signer = _key(1)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
my_address = from_script.address(network=PLM_MAINNET)
|
|
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxos = [Utxo("11" * 32, 0, 150_000_000)]
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=utxos,
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
fee = estimate_vsize(1, 2)
|
|
assert built.fee_sats == fee
|
|
assert built.recipient_sats == 10_000_000 - fee
|
|
# change reflects the full amount_sats deducted from the sender, fee comes out
|
|
# of what the recipient gets, not out of the sender's remaining balance
|
|
assert built.change_sats == 150_000_000 - 10_000_000
|
|
assert built.spent_utxos == utxos
|
|
assert len(built.txid) == 64
|
|
|
|
from embit.transaction import Transaction
|
|
|
|
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
assert len(parsed.vin[0].witness.items) == 2
|
|
assert len(parsed.vout) == 2
|
|
|
|
|
|
def test_build_signed_transaction_omits_change_output_when_exact_amount():
|
|
signer = _key(3)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
my_address = from_script.address(network=PLM_MAINNET)
|
|
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=utxos,
|
|
to_address=to_address,
|
|
amount_sats=10_000_000,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
assert built.change_sats == 0
|
|
|
|
from embit.transaction import Transaction
|
|
|
|
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
assert len(parsed.vout) == 1
|
|
|
|
|
|
def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
|
|
signer = _key(5)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
my_address = from_script.address(network=PLM_MAINNET)
|
|
to_address = script.p2wpkh(_key(6).to_public()).address(network=PLM_MAINNET)
|
|
|
|
small_amount = 100 # smaller than the ~141 sat fee at 1 sat/vB for 1-in-2-out
|
|
assert small_amount < estimate_vsize(1, 2)
|
|
utxos = [Utxo("33" * 32, 0, small_amount)]
|
|
with pytest.raises(InsufficientFundsError):
|
|
build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=utxos,
|
|
to_address=to_address,
|
|
amount_sats=small_amount,
|
|
change_address=my_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
|
|
def test_dust_change_is_left_to_the_fee():
|
|
"""B-06: `if change > 0` created change outputs below the dust limit, which makes
|
|
the whole transaction unrelayable — the bet or withdrawal then failed at broadcast
|
|
with an opaque error the user could do nothing about."""
|
|
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
|
|
|
signer = _key(1)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
|
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
|
|
|
|
amount = 10_000_000
|
|
dust_change = DUST_LIMIT_SATS - 1
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo("33" * 32, 0, amount + dust_change)],
|
|
to_address=to_address,
|
|
amount_sats=amount,
|
|
change_address=change_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
assert len(tx.vout) == 1 # no dust output
|
|
assert built.change_sats == 0
|
|
# Nothing vanishes: the dust ends up in the fee, and inputs still equal outputs+fee.
|
|
assert built.fee_sats >= dust_change
|
|
assert built.recipient_sats + built.change_sats + built.fee_sats == amount + dust_change
|
|
|
|
|
|
def test_change_at_the_dust_limit_is_still_paid_back():
|
|
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
|
|
|
signer = _key(1)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
|
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
|
|
|
|
amount = 10_000_000
|
|
built = build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo("44" * 32, 0, amount + DUST_LIMIT_SATS)],
|
|
to_address=to_address,
|
|
amount_sats=amount,
|
|
change_address=change_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|
|
|
|
assert built.change_sats == DUST_LIMIT_SATS
|
|
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vout) == 2
|
|
|
|
|
|
def test_dust_sized_recipient_amount_is_refused():
|
|
signer = _key(1)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
|
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
|
|
|
|
with pytest.raises(InsufficientFundsError):
|
|
build_signed_transaction(
|
|
signing_key=signer,
|
|
from_script=from_script,
|
|
utxos=[Utxo("55" * 32, 0, 1_000_000)],
|
|
to_address=to_address,
|
|
amount_sats=400, # after the ~160 sat fee this lands under the dust limit
|
|
change_address=change_address,
|
|
fee_rate_sat_vb=1,
|
|
)
|