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.
255 lines
9.8 KiB
Python
255 lines
9.8 KiB
Python
from dataclasses import dataclass
|
|
|
|
from embit import script
|
|
from embit.bip32 import HDKey
|
|
from embit.finalizer import finalize_psbt
|
|
from embit.psbt import PSBT
|
|
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
|
|
|
# Standard P2WPKH size estimates (vbytes): 10.5-byte overhead (version+counts+locktime+
|
|
# segwit marker/flag), ~68 vbytes per input, ~31 vbytes per output. Used to size the fee
|
|
# before signing (fee only needs to be "minimized ~1 sat/vB", not maximally precise).
|
|
_TX_OVERHEAD_VBYTES = 11
|
|
_P2WPKH_INPUT_VBYTES = 68
|
|
_P2WPKH_OUTPUT_VBYTES = 31
|
|
|
|
# BIP125 opt-in RBF: any sequence < 0xfffffffe signals replaceability. Set on every
|
|
# input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
|
|
RBF_SEQUENCE = 0xFFFFFFFD
|
|
|
|
# Below this, an output costs more to spend than it's worth and relay policy rejects
|
|
# the whole transaction as "dust" — so a small change amount must be left to the fee
|
|
# instead of being paid back to ourselves. 294 sat is the standard P2WPKH threshold
|
|
# (the output's own 31 vbytes plus the 67-vbyte input needed to spend it, at the
|
|
# 3000 sat/kvB dust relay fee). Creating such an output used to make the bet or
|
|
# withdrawal fail at broadcast with an opaque error (B-06).
|
|
DUST_LIMIT_SATS = 294
|
|
|
|
# Sanity ceiling on any transaction's fee rate — shared by RoundConfig.fee_rate_sat_vb's
|
|
# admin-facing bound (app/api/routes/admin.py, so the two can't drift apart, the same
|
|
# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF
|
|
# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) —
|
|
# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever,
|
|
# 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, 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",
|
|
**params: int | str,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.params = params
|
|
|
|
|
|
@dataclass
|
|
class Utxo:
|
|
txid: str
|
|
vout: int
|
|
amount_sats: int
|
|
|
|
|
|
@dataclass
|
|
class BuiltTransaction:
|
|
raw_hex: str
|
|
txid: str
|
|
fee_sats: int
|
|
recipient_sats: int
|
|
change_sats: int
|
|
spent_utxos: list[Utxo]
|
|
|
|
|
|
def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
|
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
|
|
|
|
|
|
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.
|
|
|
|
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:
|
|
return selected, total
|
|
raise InsufficientFundsError("not enough confirmed balance to cover amount")
|
|
|
|
|
|
def build_signed_transaction(
|
|
*,
|
|
signing_key: HDKey,
|
|
from_script: script.Script,
|
|
utxos: list[Utxo],
|
|
to_address: str,
|
|
amount_sats: int,
|
|
change_address: str,
|
|
fee_rate_sat_vb: int,
|
|
) -> BuiltTransaction:
|
|
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
|
|
back to change_address.
|
|
|
|
`amount_sats` is deducted from the sender's balance in full: the recipient
|
|
receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
|
|
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
|
|
from the amount being moved", not paid on top by the sender.
|
|
|
|
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
|
|
back to ourselves would produce an unrelayable transaction. The fee estimate
|
|
already assumes two outputs, so dropping one never underpays.
|
|
"""
|
|
selected, total_in = select_utxos(utxos, amount_sats)
|
|
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
|
recipient_amount = amount_sats - fee
|
|
if recipient_amount <= 0:
|
|
raise InsufficientFundsError(
|
|
"amount too small to cover the network fee", code="amount_below_network_fee"
|
|
)
|
|
change = total_in - amount_sats
|
|
if change < DUST_LIMIT_SATS:
|
|
fee += change # dust change is unspendable and unrelayable — miners get it
|
|
change = 0
|
|
if recipient_amount < DUST_LIMIT_SATS:
|
|
raise InsufficientFundsError(
|
|
"amount too small to be sent (dust)", code="amount_below_dust_limit"
|
|
)
|
|
|
|
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
|
|
# embit reverses it internally when serializing to wire format.
|
|
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
|
|
vout = [TransactionOutput(recipient_amount, script.Script.from_address(to_address))]
|
|
if change > 0:
|
|
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
|
|
|
|
tx = Transaction(vin=vin, vout=vout)
|
|
psbt = PSBT(tx)
|
|
for i, utxo in enumerate(selected):
|
|
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
|
|
|
|
signed_count = psbt.sign_with(signing_key)
|
|
if signed_count != len(selected):
|
|
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
|
|
|
|
final_tx = finalize_psbt(psbt)
|
|
if final_tx is None:
|
|
raise RuntimeError("failed to finalize PSBT")
|
|
|
|
raw = final_tx.serialize()
|
|
return BuiltTransaction(
|
|
raw_hex=raw.hex(),
|
|
txid=final_tx.txid().hex(),
|
|
fee_sats=fee,
|
|
recipient_sats=recipient_amount,
|
|
change_sats=change,
|
|
spent_utxos=selected,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PayoutTransaction:
|
|
raw_hex: str
|
|
txid: str
|
|
fee_sats: int
|
|
winner_sats: int
|
|
commission_sats: int
|
|
change_sats: int
|
|
spent_utxos: list[Utxo]
|
|
|
|
|
|
def build_payout_transaction(
|
|
*,
|
|
signing_key: HDKey,
|
|
from_script: script.Script,
|
|
utxos: list[Utxo],
|
|
winner_address: str,
|
|
winner_share_sats: int,
|
|
fee_address: str,
|
|
commission_sats: int,
|
|
change_address: str,
|
|
fee_rate_sat_vb: int,
|
|
) -> PayoutTransaction:
|
|
"""Build, sign and finalize the round payout: pool -> winner + fee address,
|
|
with change back to the pool itself. Per spec, only the winner's share
|
|
absorbs the tx fee — the commission (fee_address) output is untouched.
|
|
|
|
As in build_signed_transaction, dust-sized pool change is left to the fee
|
|
rather than creating an unrelayable output (B-06)."""
|
|
target = winner_share_sats + commission_sats
|
|
selected, total_in = select_utxos(utxos, target)
|
|
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
|
|
winner_amount = winner_share_sats - fee
|
|
if winner_amount < DUST_LIMIT_SATS:
|
|
raise InsufficientFundsError(
|
|
"winner share too small to cover the network fee", code="winner_share_below_network_fee"
|
|
)
|
|
if commission_sats < DUST_LIMIT_SATS:
|
|
raise InsufficientFundsError(
|
|
"commission share too small to be paid out (dust)", code="commission_below_dust_limit"
|
|
)
|
|
change = total_in - target
|
|
if change < DUST_LIMIT_SATS:
|
|
fee += change
|
|
change = 0
|
|
|
|
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
|
|
vout = [
|
|
TransactionOutput(winner_amount, script.Script.from_address(winner_address)),
|
|
TransactionOutput(commission_sats, script.Script.from_address(fee_address)),
|
|
]
|
|
if change > 0:
|
|
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
|
|
|
|
tx = Transaction(vin=vin, vout=vout)
|
|
psbt = PSBT(tx)
|
|
for i, utxo in enumerate(selected):
|
|
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
|
|
|
|
signed_count = psbt.sign_with(signing_key)
|
|
if signed_count != len(selected):
|
|
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
|
|
|
|
final_tx = finalize_psbt(psbt)
|
|
if final_tx is None:
|
|
raise RuntimeError("failed to finalize PSBT")
|
|
|
|
raw = final_tx.serialize()
|
|
return PayoutTransaction(
|
|
raw_hex=raw.hex(),
|
|
txid=final_tx.txid().hex(),
|
|
fee_sats=fee,
|
|
winner_sats=winner_amount,
|
|
commission_sats=commission_sats,
|
|
change_sats=change,
|
|
spent_utxos=selected,
|
|
)
|