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:
2026-07-27 23:30:06 +02:00
parent 6a90136b50
commit 4c80c1c5bf
9 changed files with 101 additions and 24 deletions
+29 -3
View File
@@ -33,15 +33,30 @@ DUST_LIMIT_SATS = 294
# eating further and further into the sender's change with no limit.
MAX_FEE_RATE_SAT_VB = 10_000
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
# stops being standard and gets refused at broadcast. Failing the build with a
# translatable error is the honest outcome; consolidating the address is the way out.
MAX_TX_INPUTS = 50
class InsufficientFundsError(Exception):
"""`code` is the machine-readable identifier the API layer forwards to the
client so it can translate the failure (see app/api/errors.py); the message
itself stays English."""
itself stays English, and `params` carries the values it interpolates so the
translation can place them wherever its own grammar needs them."""
def __init__(self, message: str, code: str = "insufficient_balance") -> None:
def __init__(
self,
message: str,
code: str = "insufficient_balance",
**params: int | str,
) -> None:
super().__init__(message)
self.code = code
self.params = params
@dataclass
@@ -68,11 +83,22 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering
target_sats — the amount deducted from the sender's balance. The fee is paid
out of target_sats (see build_signed_transaction), not added on top of it."""
out of target_sats (see build_signed_transaction), not added on top of it.
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
UTXOs don't cover the target, the balance is there but too fragmented to spend
in one transaction, which is a different failure from having no funds at all
and gets its own code."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
if len(selected) == MAX_TX_INPUTS:
raise InsufficientFundsError(
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
code="too_many_inputs",
max_inputs=MAX_TX_INPUTS,
)
selected.append(utxo)
total += utxo.amount_sats
if total >= target_sats: