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
@@ -102,16 +102,6 @@ decisions, see `docs/limiti-noti.md`):
|
||||
|
||||
## High
|
||||
|
||||
### B-87 — The RBF bump can leave a dust change output
|
||||
|
||||
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
|
||||
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
|
||||
by relay, every 30s.
|
||||
|
||||
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
|
||||
dropped and its value goes to the fee (the replacement is then final); `RbfError`
|
||||
and every refused bump write a `rbf_bump_failed` audit row.
|
||||
|
||||
### B-89 — Key problems surface only at first use
|
||||
|
||||
Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty;
|
||||
|
||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 670 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 681 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||
|
||||
python -m pytest # all 670 tests
|
||||
python -m pytest # all 681 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
@@ -256,14 +256,14 @@ Explicit design choices, not derivable from any single file — respect them:
|
||||
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
|
||||
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
|
||||
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
|
||||
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
|
||||
- RBF bumps are paid by whoever's output the tx reserves for it — the user's change for bets/withdrawals, the bump reserve (paid to the reserve address) for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only that one output shrinks, or is dropped into the fee when it would fall under dust (`bump_fee`, B-87).
|
||||
|
||||
## Known gaps / TODO
|
||||
|
||||
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
|
||||
|
||||
- **The `drawing` wait is unbounded by design** (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36).
|
||||
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve — big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
|
||||
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve (B-79). No extra-input fallback and no CPFP, and none would help the case that used to hurt (a transaction that already spends everything the sender has) — which is why `build_signed_transaction` guarantees a change output of at least `DUST_LIMIT_SATS` (B-62) and every payout carries the reserve. Since B-87 a bump that would leave that output under dust drops it and gives its whole value to the fee (the replacement is then the last possible one); a bump with no output, or one whose output cannot even pay BIP125's minimum increase, raises `RbfError`. Every such failure — and `max_fee_rate` and a replacement the network refuses — is written by `RbfBumper._record_failure` as one `rbf_bump_failed` audit row per (current txid, reason). What stays accepted: a transaction that has exhausted its change or reserve and still does not confirm waits, visibly, and is never abandoned while a server knows it.
|
||||
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
||||
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
||||
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
670 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
681 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
+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)
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-19 — Ogni transazione utente conserva un'uscita di resto verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-87)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -31,7 +31,4 @@ e di [TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-
|
||||
utente: la sua uscita riducibile è la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md).
|
||||
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
|
||||
*Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il
|
||||
resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo
|
||||
inoltra.
|
||||
*Test:* `tests/unit/test_rbf_dust.py::test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump`, `tests/unit/test_rbf_dust.py::test_the_dust_boundary_of_what_a_bump_leaves`, `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
|
||||
+11
-13
@@ -44,9 +44,9 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/electrum/listener.py:ElectrumListener._record_reorg`
|
||||
*Stato:* da implementare (B-87, B-90, B-96), per tre degli eventi
|
||||
nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro e
|
||||
`chain_reorg_detected`.
|
||||
*Stato:* da implementare (B-90, B-96), per due degli eventi
|
||||
nuovi · *Oggi:* gli eventi osservativi sono i primi quattro,
|
||||
`chain_reorg_detected` e `rbf_bump_failed`.
|
||||
|
||||
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
|
||||
`payload_json`. `created_at` è impostato dall'applicazione, non dal
|
||||
@@ -115,9 +115,9 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
|
||||
*Stato:* da implementare (B-87, B-96), per le ultime due letture ·
|
||||
*Oggi:* le letture sono quelle di `payout_failed` e di
|
||||
`draw_baseline_tip_unknown`.
|
||||
*Stato:* da implementare (B-96), per l'ultima lettura ·
|
||||
*Oggi:* le letture sono quelle di `payout_failed`, di
|
||||
`draw_baseline_tip_unknown` e di `rbf_bump_failed`.
|
||||
|
||||
## Catalogo degli eventi
|
||||
|
||||
@@ -590,10 +590,8 @@ flowchart LR
|
||||
nel registro una riga uguale ([AUD-09]); un nuovo fallimento con la stessa
|
||||
coppia va solo nei log, mentre un nuovo txid (dopo un aumento riuscito) o un
|
||||
motivo diverso scrivono una riga nuova.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-87) · *Oggi:* l'errore va solo nel log
|
||||
(`could not bump pending_transaction N`), ogni 30 secondi.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._record_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_rbf_dust.py::test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason`, `tests/unit/test_rbf_dust.py::test_the_maximum_fee_rate_is_reported`, `tests/unit/test_rbf_dust.py::test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone`, `tests/unit/test_rbf_dust.py::test_a_new_txid_or_a_new_reason_writes_a_new_row`
|
||||
|
||||
### Catena
|
||||
|
||||
@@ -636,9 +634,9 @@ flowchart LR
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/confirmation.py:_on_payout_confirmed`,
|
||||
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
|
||||
*Stato:* da implementare (B-87, B-92, B-94), per i tre fatti che escono
|
||||
dall'elenco · *Oggi:* gli RBF impossibili, la registrazione e il cambio
|
||||
password riuscito non producono alcuna riga.
|
||||
*Stato:* da implementare (B-92, B-94), per i due fatti che escono
|
||||
dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non
|
||||
producono alcuna riga.
|
||||
|
||||
## Log applicativo
|
||||
|
||||
|
||||
+27
-34
@@ -33,7 +33,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|
||||
|---|---|---|
|
||||
| ~~[LIM-01]~~ `drawing` non riprende dopo un errore o un riavvio | Non più (chiuso da B-81) | — |
|
||||
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
|
||||
| [LIM-02] RBF di una sola forma | Non più il pagamento senza resto (B-79) né il resto sotto soglia (B-87); resta una transazione che esaurisce resto o riserva | Visibile in `rbf_bump_failed`; resta in attesa senza abbandono |
|
||||
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
|
||||
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
|
||||
| ~~[LIM-10]~~ Riorganizzazioni non gestite | Non più (chiuso da B-82 e B-85) | Resta [LIM-24] |
|
||||
@@ -50,38 +50,31 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
stesso vincitore ([RND-36](ciclo-di-vita-del-round.md)).
|
||||
|
||||
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
|
||||
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
|
||||
il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB
|
||||
sull'intera dimensione della transazione (141 sat per una transazione
|
||||
con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera
|
||||
il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla
|
||||
che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino
|
||||
al minimo di 294 sat ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)) già il primo aumento produce un'uscita sotto
|
||||
soglia, che le regole di inoltro standard rifiutano. **Il pagamento del
|
||||
round, nel caso normale, non ha alcun resto** (spende esattamente il
|
||||
montepremi del round, e un resto sotto soglia va alla commissione), quindi
|
||||
non è accelerabile affatto.
|
||||
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB può
|
||||
restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo
|
||||
ciò che il server **non conosce**, una transazione ferma ma presente in
|
||||
mempool non viene mai abbandonata: una scommessa così blocca il round in
|
||||
`closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper`
|
||||
registra un errore ogni 30s.
|
||||
- *Mitigazione attuale:* resto minimo garantito sulle transazioni utente;
|
||||
tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che
|
||||
sparisce dalla rete; ritentativo automatico del pagamento dopo un
|
||||
abbandono.
|
||||
- *Stato:* da chiudere con B-87 (la riserva del pagamento, B-79, c'è).
|
||||
- *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che
|
||||
l'accelerazione riduce ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79); un aumento che lascerebbe un
|
||||
resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in
|
||||
commissione, e quella sostituzione è l'ultima ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md), B-87); ogni
|
||||
`RbfError` e ogni sostituzione rifiutata scrivono `rbf_bump_failed` in
|
||||
audit. Resta accettato, dopo il piano: nessun ripiego «aggiungi un'altra
|
||||
moneta» né CPFP; una transazione che ha esaurito resto o riserva e non
|
||||
conferma nemmeno al tetto del fee rate resta in attesa, visibile, e non
|
||||
viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)). Il docstring di `bump_fee` cita ancora come
|
||||
esempio «una scommessa senza resto», caso che dopo B-62 non esiste più.
|
||||
riducibile — il resto verso il mittente per scommesse e prelievi, l'uscita
|
||||
di riserva per il pagamento ([RND-38](ciclo-di-vita-del-round.md#4--pagamento), B-79).
|
||||
Non c'è il ripiego «aggiungi un'altra moneta» né il CPFP. Ogni aumento costa
|
||||
almeno 1 sat/vB sull'intera dimensione della transazione (141 sat per una
|
||||
transazione con un ingresso e due uscite) e si toglie da quell'uscita; se
|
||||
quanto resta sarebbe sotto la soglia di polvere l'uscita viene eliminata e
|
||||
il suo valore va in commissione, e quella sostituzione è l'ultima (B-87,
|
||||
[ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)); se
|
||||
nemmeno l'intera uscita raggiunge l'aumento minimo, o non esiste, `bump_fee`
|
||||
solleva `RbfError`.
|
||||
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB che ha
|
||||
esaurito resto o riserva può restare in mempool indefinitamente. Poiché il
|
||||
riconciliatore abbandona solo ciò che il server **non conosce**, una
|
||||
transazione ferma ma presente in mempool non viene mai abbandonata: una
|
||||
scommessa così blocca il round in `closing`, un pagamento così lo blocca
|
||||
in `paying_out`.
|
||||
- *Mitigazione attuale:* la riserva del pagamento è dimensionata per circa
|
||||
10 sat/vB; resto minimo garantito sulle transazioni utente; tetto al fee
|
||||
rate (`MAX_FEE_RATE_SAT_VB`); ogni accelerazione impossibile o rifiutata
|
||||
scrive `rbf_bump_failed` (una volta per transazione e motivo); abbandono
|
||||
dopo 6 ore per ciò che sparisce dalla rete; ritentativo automatico del
|
||||
pagamento dopo un abbandono.
|
||||
- *Stato:* accettato dopo B-79 e B-87: una transazione che ha esaurito resto
|
||||
o riserva e non conferma nemmeno al tetto del fee rate resta in attesa,
|
||||
visibile, e non viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)).
|
||||
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once`
|
||||
|
||||
@@ -95,7 +88,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
nuovo round si apre nel frattempo.
|
||||
- *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello
|
||||
«Transazioni pendenti» mostra la scommessa che trattiene il round.
|
||||
- *Stato:* B-78 e B-77 chiusi; resta da chiudere B-87 (resto minimo), e per scelta l'assenza di un limite di tempo.
|
||||
- *Stato:* B-78, B-77 e B-87 chiusi; resta per scelta l'assenza di un limite di tempo.
|
||||
- *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma
|
||||
(una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non
|
||||
confermerà mai ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)), non dopo un tempo fisso; con B-87 la scommessa
|
||||
|
||||
+12
-12
@@ -608,15 +608,18 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
### RBF impossibile
|
||||
|
||||
- **[OPS-17]**
|
||||
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
|
||||
`could not bump pending_transaction N` con un `RbfError`: "no change output
|
||||
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
|
||||
Nessuna riga di audit. `attempt_count` smette di salire. Un aumento che la
|
||||
rete rifiuta (per esempio perché il resto scenderebbe sotto la soglia di
|
||||
polvere) compare invece come errore di invio nello stesso messaggio.
|
||||
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
|
||||
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
|
||||
non si può fare. Un pagamento ha un'uscita di riserva
|
||||
**Sintomi.** Una riga `rbf_bump_failed` nel registro di audit (sezione
|
||||
Audit log del pannello, con `pending_transaction_id`, `txid` e `reason`):
|
||||
`change_too_small`, `max_fee_rate` o `replacement_rejected`. Se ne scrive una
|
||||
sola per transazione e motivo; nel log, ogni 30 secondi, resta
|
||||
`could not bump pending_transaction N`. `attempt_count` smette di salire.
|
||||
**Causa.** L'RBF riduce un'unica uscita, il resto che torna al mittente; se
|
||||
l'aumento la porterebbe sotto la soglia di polvere l'uscita viene eliminata
|
||||
e il suo valore va in commissione, e quella sostituzione è l'ultima
|
||||
(`change_too_small` ai tentativi successivi). Non si può fare nemmeno se
|
||||
l'uscita non esiste o non basta all'aumento minimo, o se la commissione è già
|
||||
al massimo (10.000 sat/vB, `max_fee_rate`); `replacement_rejected` è una
|
||||
sostituzione che la rete ha rifiutato esplicitamente. Un pagamento ha un'uscita di riserva
|
||||
(B-79) che l'aumento riduce; la riserva è dimensionata per arrivare a circa
|
||||
10 sat/vB, oltre la quale (o per un pagamento costruito prima di B-79, che
|
||||
non ce l'ha) la transazione non si può accelerare.
|
||||
@@ -628,9 +631,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un
|
||||
normale deposito. Per un pagamento non c'è un'alternativa supportata: la
|
||||
chiave del montepremi non è esportabile ([OPS-24]).
|
||||
Dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere
|
||||
toglie il resto invece di fallire, e ogni fallimento scrive
|
||||
`rbf_bump_failed`.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
|
||||
|
||||
+3
-3
@@ -235,7 +235,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
|
||||
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | *da registrare* | `test_electrum_listener.py` | ELC, RND |
|
||||
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | *da registrare* | `test_electrum_listener.py` | ELC, OPS |
|
||||
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
|
||||
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX |
|
||||
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
|
||||
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS |
|
||||
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
|
||||
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
|
||||
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
|
||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82.
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87.
|
||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici,
|
||||
4 alti, 6 medi), da implementare nell'ordine di
|
||||
3 alti, 6 medi), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-99`.
|
||||
@@ -243,11 +243,7 @@ sostituibilità fin dal primo invio.
|
||||
partita ([TX-17]): finché almeno un server la tiene in mempool non viene
|
||||
mai abbandonata.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-87) · *Oggi:* il pagamento si accelera già
|
||||
dall'uscita di riserva (B-79), ma per ogni transazione basta che l'uscita
|
||||
superi l'aumento: un resto di 294 sat diventa 153 sat, sotto la soglia di
|
||||
dust, e la rete rifiuta la sostituzione a ogni giro.
|
||||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_rbf_dust.py`
|
||||
- **[TX-12]** **Prima si scrive la sostituzione, poi si invia.** La
|
||||
sostituzione firmata viene registrata come candidata in
|
||||
`pending_transaction_txids` (txid e transazione completa, [TX-19]) e
|
||||
@@ -282,10 +278,8 @@ sostituibilità fin dal primo invio.
|
||||
tentativo che fallisce di nuovo per lo stesso motivo sullo stesso txid va
|
||||
solo nei log, così una transazione non accelerabile non riempie il registro
|
||||
con una riga ogni 30 secondi.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-87) · *Oggi:* l'errore finisce solo nei log
|
||||
dell'applicazione, invisibile da `/admin`.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:RbfBumper._record_failure` ·
|
||||
*Test:* `tests/unit/test_rbf_dust.py`
|
||||
|
||||
Il resto minimo di scommesse e prelievi è di 294 sat
|
||||
([BET-14](scommesse-e-prelievi.md#cosa-succede),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""B-87 (TX-11, TX-24, AUD-40, AUD-09): a bump never leaves a dust-sized output, and every bump
|
||||
that cannot be made is visible from /admin.
|
||||
|
||||
`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, a replacement with a
|
||||
sub-dust output is refused by relay policy, and the bumper retried it every 30 seconds for
|
||||
ever with nothing but a log line to show for it.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.finalizer import finalize_psbt
|
||||
from embit.psbt import PSBT
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
|
||||
from app.tx.broadcast import RbfBumper, RbfError, bump_fee
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
DUST_LIMIT_SATS,
|
||||
MAX_FEE_RATE_SAT_VB,
|
||||
RBF_SEQUENCE,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
estimate_vsize,
|
||||
)
|
||||
from tests.unit.fake_network import FakeNetwork, rpc_error
|
||||
from tests.unit.test_broadcast import FakeClient, _key, _seed_payout, session_factory # noqa: F401 (fixture)
|
||||
|
||||
_AMOUNT = 10_000_000
|
||||
_DELTA = estimate_vsize(1, 2) # the first minimum bump of a one-input, two-output tx at 1 sat/vB: 141 sat
|
||||
|
||||
|
||||
def _stale() -> datetime:
|
||||
return datetime.now(timezone.utc) - timedelta(seconds=5000)
|
||||
|
||||
|
||||
async def _pending_bet(session_factory, *, change: int, fee_rate: int = 1, username: str = "alice"):
|
||||
"""A real bet whose change output is exactly `change` sats, in `pending`, due for a bump."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
address = derive_user_address(0)
|
||||
utxo_txid = "11" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=script.p2wpkh(signer.to_public()),
|
||||
utxos=[Utxo(utxo_txid, 0, _AMOUNT + change)],
|
||||
to_address=script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET),
|
||||
amount_sats=_AMOUNT,
|
||||
change_address=address,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
assert built.change_sats == change
|
||||
async with session_factory() as session:
|
||||
user = User(username=username, password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
row = PendingTransaction(
|
||||
kind="bet", user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=fee_rate,
|
||||
raw_tx_hex=built.raw_hex, status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row.id, built, FakeClient({utxo_txid: _AMOUNT + change})
|
||||
|
||||
|
||||
def _outputs(raw_hex: str) -> list[int]:
|
||||
return [o.value for o in Transaction.parse(bytes.fromhex(raw_hex)).vout]
|
||||
|
||||
|
||||
# --- the change that would fall under dust is dropped into the fee (TX-11) ------------------------------
|
||||
|
||||
|
||||
async def test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump(session_factory):
|
||||
"""The case of the finding: a 294 sat change. The replacement carries one output (the
|
||||
recipient's, untouched), pays the whole change as fee, and is the last one possible."""
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
|
||||
|
||||
new_txid = await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
assert new_txid is not None and new_txid != built.txid
|
||||
old, new = _outputs(built.raw_hex), _outputs(client.broadcasted[0])
|
||||
assert len(old) == 2 and len(new) == 1
|
||||
assert new[0] == old[0] # the recipient is never touched
|
||||
assert sum(old) - sum(new) == DUST_LIMIT_SATS # the whole change went to the fee
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.current_txid == new_txid and row.status == "pending"
|
||||
assert row.fee_rate_sat_vb == (built.fee_sats + DUST_LIMIT_SATS) // estimate_vsize(1, 1)
|
||||
|
||||
# nothing is left to reduce: any further bump is impossible, with the reason to show for it
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
with pytest.raises(RbfError) as refused:
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
assert refused.value.reason == "change_too_small" and len(client.broadcasted) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change, kept",
|
||||
[
|
||||
pytest.param(DUST_LIMIT_SATS + _DELTA, True, id="leaves-exactly-dust-limit-and-is-kept"),
|
||||
pytest.param(DUST_LIMIT_SATS + _DELTA - 1, False, id="one-sat-under-and-is-dropped"),
|
||||
pytest.param(DUST_LIMIT_SATS + _DELTA + 1_000, True, id="plenty-and-is-kept"),
|
||||
],
|
||||
)
|
||||
async def test_the_dust_boundary_of_what_a_bump_leaves(session_factory, change, kept):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=change)
|
||||
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
new = _outputs(client.broadcasted[0])
|
||||
if kept:
|
||||
assert len(new) == 2 and new[1] == change - _DELTA >= DUST_LIMIT_SATS
|
||||
else:
|
||||
assert len(new) == 1
|
||||
assert sum(_outputs(built.raw_hex)) - sum(new) == change # all of it, not just the delta
|
||||
|
||||
|
||||
async def test_a_change_that_cannot_even_pay_the_minimum_increase_cannot_be_bumped(session_factory):
|
||||
"""Giving the whole output to the fee must still reach BIP125's minimum increase."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
address = derive_user_address(0)
|
||||
legacy = Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex("22" * 32), 0, sequence=RBF_SEQUENCE)],
|
||||
vout=[
|
||||
TransactionOutput(9_999_000, script.Script.from_address(script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET))),
|
||||
TransactionOutput(_DELTA - 1, script.Script.from_address(address)),
|
||||
],
|
||||
)
|
||||
async with session_factory() as session:
|
||||
user = User(username="bob", password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
row = PendingTransaction(
|
||||
kind="bet", user_id=user.id, current_txid=legacy.txid().hex(), fee_rate_sat_vb=1,
|
||||
raw_tx_hex=legacy.serialize().hex(), status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
pending_id = row.id
|
||||
client = FakeClient({"22" * 32: 10_000_000})
|
||||
with pytest.raises(RbfError):
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
assert client.broadcasted == []
|
||||
|
||||
|
||||
async def test_a_payouts_bump_reserve_is_dropped_the_same_way(session_factory):
|
||||
"""TX-11 names the reserve too: when a bump would shrink it under dust it is dropped, and
|
||||
the winner's and the fee address' outputs are still byte-for-byte what they were."""
|
||||
built, pending_id, prevouts = await _seed_payout(session_factory, n=1, fee_rate=1)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
row.fee_rate_sat_vb = 9 # the next target (10 sat/vB) leaves 172 sat of a 1 720 sat reserve
|
||||
await session.commit()
|
||||
client = FakeClient(prevouts)
|
||||
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
assert len(old_tx.vout) == 3 and len(new_tx.vout) == 2
|
||||
assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize()
|
||||
assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize()
|
||||
assert sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout) == old_tx.vout[2].value
|
||||
|
||||
|
||||
# --- every refused bump is visible (TX-24, AUD-40, AUD-09) ------------------------------------------------------
|
||||
|
||||
|
||||
def _bumper(session_factory, client, network=None):
|
||||
return RbfBumper(session_factory, lambda: client, network=network or FakeNetwork())
|
||||
|
||||
|
||||
async def _failures(session_factory) -> list[dict]:
|
||||
async with session_factory() as session:
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "rbf_bump_failed"))).all()
|
||||
return [dict(json.loads(r.payload_json), user_id=r.user_id, round_id=r.round_id) for r in rows]
|
||||
|
||||
|
||||
async def test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason(session_factory):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
|
||||
bumper = _bumper(session_factory, client)
|
||||
|
||||
await bumper._tick(client) # succeeds: the change is dropped
|
||||
assert await _failures(session_factory) == []
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
for _ in range(3): # three 30 second rounds with the same impossible bump
|
||||
await bumper._tick(client)
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
|
||||
failures = await _failures(session_factory)
|
||||
assert len(failures) == 1 # once, not once per tick
|
||||
row = failures[0]
|
||||
assert row["reason"] == "change_too_small" and row["kind"] == "bet"
|
||||
assert row["pending_transaction_id"] == pending_id and row["user_id"] is not None and row["round_id"] is None
|
||||
async with session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
assert row["txid"] == pending.current_txid != built.txid # the current txid, i.e. the one that cannot be bumped
|
||||
assert pending.status == "pending" # the row itself is untouched (TX-24)
|
||||
assert len(row["message"]) <= 200
|
||||
|
||||
|
||||
async def test_the_maximum_fee_rate_is_reported(session_factory):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000, fee_rate=MAX_FEE_RATE_SAT_VB)
|
||||
await _bumper(session_factory, client)._tick(client)
|
||||
failures = await _failures(session_factory)
|
||||
assert [f["reason"] for f in failures] == ["max_fee_rate"] and failures[0]["txid"] == built.txid
|
||||
assert client.broadcasted == []
|
||||
|
||||
|
||||
async def test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone(session_factory):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
|
||||
|
||||
async def refuse(raw):
|
||||
raise rpc_error("min relay fee not met")
|
||||
|
||||
client.broadcast = refuse
|
||||
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
|
||||
await bumper._tick(client)
|
||||
|
||||
failures = await _failures(session_factory)
|
||||
assert [f["reason"] for f in failures] == ["replacement_rejected"]
|
||||
assert "min relay fee not met" in failures[0]["message"]
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(PendingTransaction, pending_id)).current_txid == built.txid
|
||||
|
||||
|
||||
async def test_a_new_txid_or_a_new_reason_writes_a_new_row(session_factory):
|
||||
"""AUD-09: the pair (current txid, reason) is what makes two failures the same one."""
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
|
||||
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
|
||||
|
||||
async def refuse(raw):
|
||||
raise rpc_error("min relay fee not met")
|
||||
|
||||
real_broadcast = client.broadcast
|
||||
client.broadcast = refuse
|
||||
for _ in range(2):
|
||||
await bumper._tick(client)
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
assert [f["reason"] for f in await _failures(session_factory)] == ["replacement_rejected"] # same pair: once
|
||||
|
||||
client.broadcast = real_broadcast # the bump now succeeds ...
|
||||
await bumper._tick(client)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.current_txid != built.txid
|
||||
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB # ... and the next one hits the ceiling: new txid, new reason
|
||||
row.last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
await bumper._tick(client)
|
||||
failures = await _failures(session_factory)
|
||||
assert [f["reason"] for f in failures] == ["replacement_rejected", "max_fee_rate"]
|
||||
assert failures[0]["txid"] == built.txid and failures[1]["txid"] != built.txid
|
||||
|
||||
|
||||
async def test_a_payout_failure_carries_its_round(session_factory):
|
||||
built, pending_id, prevouts = await _seed_payout(session_factory, n=1)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB
|
||||
row.last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
client = FakeClient(prevouts)
|
||||
await _bumper(session_factory, client)._tick(client)
|
||||
failures = await _failures(session_factory)
|
||||
assert len(failures) == 1 and failures[0]["kind"] == "payout" and failures[0]["round_id"] == 1
|
||||
assert failures[0]["user_id"] is None
|
||||
Reference in new issue
Block a user