Cap the number of inputs a transaction may spend (B-48)
select_utxos had no ceiling on input count, so an address fragmented into many small deposits built an ever-larger transaction whose fee — deducted from the amount being moved — eroded the bet's share of the pool or the withdrawn amount, and past a few hundred inputs stopped being standard at all. MAX_TX_INPUTS (50) now bounds the selection. Reaching the cap without covering the target is reported as its own "too_many_inputs" code, distinct from having no funds, with the cap carried in the error params for the 7 translations. The payout path records the same distinction in its payout_failed audit reason.
This commit is contained in:
@@ -10,6 +10,7 @@ from app.db.base import Base
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
from app.wallet.hd import derive_user_address
|
||||
from app.wallet.psbt_builder import MAX_TX_INPUTS
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
@@ -91,6 +92,35 @@ async def test_place_bet_rejects_insufficient_balance(session_factory):
|
||||
await place_bet(session, client, user)
|
||||
|
||||
|
||||
async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48
|
||||
# 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs
|
||||
# only add up to 7.5 PLM — so the build must fail with its own code, not with
|
||||
# the "you have no funds" one, and must carry the cap for the translation.
|
||||
user_id = await _make_funded_user(session_factory, 20, 15_000_000)
|
||||
async with session_factory() as session:
|
||||
for i in range(99):
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user_id,
|
||||
txid=f"{i:064x}",
|
||||
vout=0,
|
||||
amount_sats=15_000_000,
|
||||
confirmed_height=100,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "too_many_inputs"
|
||||
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
|
||||
assert not client.broadcasted
|
||||
|
||||
|
||||
async def test_place_bet_rejects_second_bet_same_round(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
@@ -5,6 +5,7 @@ from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_TX_INPUTS,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
@@ -36,6 +37,22 @@ def test_select_utxos_raises_when_insufficient():
|
||||
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_build_signed_transaction_deducts_fee_from_amount_not_change():
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
|
||||
Reference in New Issue
Block a user