Drop a dust-sized change into the fee on an RBF bump and audit every refused bump (B-87, TX-11, TX-24, AUD-40, AUD-09, ADR-19)
`bump_fee` only required `change > fee delta`. A change at the 294 sat minimum (a full-balance
withdrawal) shrank to ~153 sat after the very first minimum bump, relay policy refuses a
replacement with a sub-dust output, and the bumper tried again every 30 seconds for ever, with
nothing but a log line to show for it.
- When what would be left of the reducible output (the user's change, a payout's bump
reserve) is under DUST_LIMIT_SATS the output is dropped and its whole value goes to the fee:
that replacement is valid and the last one possible. If the output does not exist, or even
its whole value is below BIP125's minimum increase (vsize x 1 sat/vB), RbfError. The
recorded fee rate uses the replacement's own, smaller, size.
- RbfError now carries a `reason` (`change_too_small`, `max_fee_rate`, `replacement_rejected`);
RbfBumper._record_failure writes one `rbf_bump_failed` row {pending_transaction_id, kind,
txid, reason, message[:200]} with the row's user_id/round_id, in a transaction of its own,
once per (current txid, reason) - the audit log is the dedupe clock (AUD-09) - and the pending
row is untouched. A new txid or a new reason writes a new row.
- bump_fee's docstring no longer cites the "bet without change" case that B-62 removed.
No existing test changed (all bumps they make leave a change well above dust). New
tests/unit/test_rbf_dust.py: the 294 sat case end to end (recipient untouched, whole change to
the fee, last bump impossible), the exact boundary (leaves 294 kept / leaves 293 dropped), a
change under the minimum increase, a payout's reserve dropped with the winner and fee outputs
byte-identical, and the audit rows for each reason, once per pair, new row on a new txid or
reason, round/user attribution. Mutation-checked: the dust test of the output and the
once-per-pair check each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54dbffb552
commit
ccd15807ed
11 files changed
+440
-108
No files matched your search
+93
-17
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -9,8 +10,10 @@ from embit.finalizer import finalize_psbt
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import (
|
||||
AuditLog,
|
||||
PendingTransaction,
|
||||
PendingTransactionTxid,
|
||||
Round,
|
||||
@@ -25,7 +28,7 @@ from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_
|
||||
from app.tx.versions import record_version
|
||||
from app.wallet.hd import derive_pool_address, derive_round_pool_key, derive_user_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS, MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,8 +42,17 @@ _FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by
|
||||
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
|
||||
|
||||
|
||||
# B-87 (TX-24, AUD-40): why a bump could not be made — the `reason` of the `rbf_bump_failed`
|
||||
# audit row the bumper writes.
|
||||
REASON_CHANGE_TOO_SMALL = "change_too_small"
|
||||
REASON_MAX_FEE_RATE = "max_fee_rate"
|
||||
REASON_REPLACEMENT_REJECTED = "replacement_rejected"
|
||||
|
||||
|
||||
class RbfError(Exception):
|
||||
pass
|
||||
def __init__(self, message: str, reason: str = REASON_CHANGE_TOO_SMALL):
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
||||
@@ -145,13 +157,16 @@ async def bump_fee(
|
||||
matched is a conflict: the row is left alone, the candidate stays recorded, and
|
||||
the reconciler resolves it.
|
||||
|
||||
Only handles the common case: exactly one change output paying back to the
|
||||
tx's own sender address, large enough to absorb the increase. If there's no
|
||||
such output (e.g. an exact-amount bet with no change), this raises RbfError —
|
||||
bumping such a tx would require selecting additional inputs, which isn't
|
||||
implemented for the MVP; it needs manual operator intervention. Also raises
|
||||
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
|
||||
(B-32), and when the network explicitly refuses the replacement (TX-24).
|
||||
Only handles one shape: exactly one output to take the increase from — the sender's
|
||||
own change, or a payout's bump reserve (B-79) — and when what would be left of it is
|
||||
below the dust limit the output is dropped and its whole value goes to the fee, which
|
||||
makes that replacement the last possible one (B-87, TX-11). With no such output, or
|
||||
when even its whole value is below BIP125's minimum increase, this raises RbfError —
|
||||
adding inputs would not help the case that used to hurt (a transaction that already
|
||||
spends everything the sender has). Also raises RbfError, rather than bumping, once the
|
||||
row is already at MAX_FEE_RATE_SAT_VB (B-32), and when the network explicitly refuses
|
||||
the replacement (TX-24). Each carries a `reason` for the `rbf_bump_failed` audit row
|
||||
(RbfBumper._record_failure).
|
||||
"""
|
||||
# --- Phase 1: read what's needed, close the session before any network call ---
|
||||
async with session_factory() as session:
|
||||
@@ -162,7 +177,8 @@ async def bump_fee(
|
||||
if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
|
||||
raise RbfError(
|
||||
f"pending_transaction {pending_id}: already at the maximum fee rate "
|
||||
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further"
|
||||
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further",
|
||||
REASON_MAX_FEE_RATE,
|
||||
)
|
||||
|
||||
kind = pending.kind
|
||||
@@ -193,12 +209,28 @@ async def bump_fee(
|
||||
fee_delta = max(target_fee - old_fee, min_valid_delta)
|
||||
|
||||
change_index = _find_reducible_output(old_tx, kind, own_address)
|
||||
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
|
||||
raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
|
||||
if change_index is None:
|
||||
raise RbfError(f"pending_transaction {pending_id}: there is no output to take a fee bump from")
|
||||
reducible_value = old_tx.vout[change_index].value
|
||||
if reducible_value < min_valid_delta:
|
||||
# Not even giving the whole output to the fee reaches BIP125's minimum increase.
|
||||
raise RbfError(
|
||||
f"pending_transaction {pending_id}: its change ({reducible_value} sat) cannot even cover the "
|
||||
f"minimum relay increase ({min_valid_delta} sat)"
|
||||
)
|
||||
|
||||
new_vout = list(old_tx.vout)
|
||||
bumped_change = new_vout[change_index].value - fee_delta
|
||||
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
||||
bumped_change = reducible_value - fee_delta
|
||||
if bumped_change >= DUST_LIMIT_SATS:
|
||||
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
||||
else:
|
||||
# B-87 (TX-11): what would be left is below the dust limit, an output no node relays — a
|
||||
# 294 sat change shrinks to ~153 after the very first minimum bump, and every replacement
|
||||
# kept by it was refused, every 30 seconds, for ever. Drop the output instead and give its
|
||||
# whole value to the fee: the replacement is valid, and it is the last one this
|
||||
# transaction can have, since nothing is left to reduce.
|
||||
del new_vout[change_index]
|
||||
fee_delta = reducible_value
|
||||
|
||||
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
|
||||
new_tx = Transaction(vin=new_vin, vout=new_vout)
|
||||
@@ -232,7 +264,10 @@ async def bump_fee(
|
||||
# The network explicitly refused the replacement and no server knows it: the row
|
||||
# is unchanged and keeps the version that is on the network (TX-24). The
|
||||
# candidate stays recorded — it was never relayed, so nobody will confirm it.
|
||||
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
|
||||
raise RbfError(
|
||||
f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}",
|
||||
REASON_REPLACEMENT_REJECTED,
|
||||
)
|
||||
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
|
||||
# follows it (TX-12); the old version stays recorded and is still recognised should
|
||||
# it be the one that confirms.
|
||||
@@ -259,7 +294,7 @@ async def bump_fee(
|
||||
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
|
||||
current_txid=new_txid,
|
||||
raw_tx_hex=raw_hex,
|
||||
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
|
||||
fee_rate_sat_vb=(old_fee + fee_delta) // estimate_vsize(len(new_vin), len(new_vout)),
|
||||
attempt_count=PendingTransaction.attempt_count + 1,
|
||||
last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -367,6 +402,46 @@ class RbfBumper:
|
||||
logger.exception("RBF bump tick failed")
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
async def _record_failure(self, pending_id: int, exc: RbfError) -> None:
|
||||
"""B-87 (TX-24, AUD-40, AUD-09): a bump that cannot be made is visible from /admin as one
|
||||
`rbf_bump_failed` row — in a transaction of its own, the row stays `pending` — and only
|
||||
**once per (current txid, reason)**: the same failure on the same transaction every 30
|
||||
seconds goes to the log alone, while a new txid (after a successful bump) or another
|
||||
reason writes a new row."""
|
||||
try:
|
||||
async with self._session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
if pending is None:
|
||||
return
|
||||
already = (
|
||||
await session.scalars(
|
||||
select(AuditLog.payload_json).where(
|
||||
AuditLog.event_type == "rbf_bump_failed",
|
||||
AuditLog.payload_json.contains(f'"txid": "{pending.current_txid}"'),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for payload_json in already:
|
||||
payload = json.loads(payload_json)
|
||||
if payload.get("txid") == pending.current_txid and payload.get("reason") == exc.reason:
|
||||
return
|
||||
await write_audit_log(
|
||||
session,
|
||||
"rbf_bump_failed",
|
||||
{
|
||||
"pending_transaction_id": pending.id,
|
||||
"kind": pending.kind,
|
||||
"txid": pending.current_txid,
|
||||
"reason": exc.reason,
|
||||
"message": str(exc)[:200],
|
||||
},
|
||||
user_id=pending.user_id,
|
||||
round_id=pending.round_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("could not record the failed bump of pending_transaction %s", pending_id)
|
||||
|
||||
async def _tick(self, client: ElectrumClient) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
async with self._session_factory() as session:
|
||||
@@ -379,7 +454,8 @@ class RbfBumper:
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(self._session_factory, client, pending_id, network=self._network)
|
||||
except RbfError:
|
||||
except RbfError as exc:
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
await self._record_failure(pending_id, exc)
|
||||
except Exception:
|
||||
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
|
||||
Reference in new issue
Block a user