bump_fee computed fee_delta as new_fee - old_fee, falling back to a flat 1-satoshi bump whenever that came out zero or negative - which happened whenever old_fee (the actual fee paid, from real prevout amounts) already exceeded the naive target, e.g. because dust change had been folded into the original fee (psbt_builder.py's DUST_LIMIT_SATS handling). A 1-satoshi total increase is nowhere near BIP125 rule 4's minimum (the replacement must pay at least the incremental relay fee rate times its own vsize more than what it replaces), so the node rejected it every time - and since bump_fee raised before touching `pending`, the next tick retried with identical parameters every 30 seconds, forever. Separately, the fee rate climbed by 1 sat/vB every bump with no ceiling. fee_delta is now max(target_fee - old_fee, vsize * the incremental relay rate) - always at least the relay-mandated minimum regardless of what the naive arithmetic produces. pending.fee_rate_sat_vb is set to the actual resulting rate rather than the naive target, so a later bump's arithmetic starts from what's really being paid instead of drifting from it. Once a transaction reaches MAX_FEE_RATE_SAT_VB (a new constant, 10,000 sat/vB, shared with RoundConfig.fee_rate_sat_vb's existing admin-facing bound so the two can't drift apart - the same reason MIN_PASSWORD_LENGTH is shared elsewhere) bump_fee refuses to bump further; the reconciler abandons it if it never confirms (B-27) instead of this retrying forever. Suite grows from 185 to 187 tests. BUGS.md moves B-32 to Previously fixed.
229 lines
8.6 KiB
Python
229 lines
8.6 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
|
|
|
|
|
|
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."""
|
|
|
|
def __init__(self, message: str, code: str = "insufficient_balance") -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
@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."""
|
|
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
|
selected: list[Utxo] = []
|
|
total = 0
|
|
for utxo in ordered:
|
|
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,
|
|
)
|