Commit Graph
8 Commits
Author SHA1 Message Date
davide 4124dc08e6 Check confirmation/existence via scripthash history, not verbose replies (B-41)
poll_once and reconcile.py's existence check both called
blockchain.transaction.get(txid, verbose=True). Several Electrum server
implementations and versions reject the verbose flag outright
("verbose transactions are currently unsupported"), which would have
meant no confirmations and no reconciliation ever running against such
a server, read as a plain transport error. reconcile.py additionally
decided whether to abandon a transaction - releasing its funds - by
substring-matching the error text ("missing", "not found", ...), which
only works against ElectrumX's specific wording.

Both now ask blockchain.scripthash.get_history for the address that
owns every input of the transaction (a user's own address for a
bet/withdrawal, the pool address for a payout) and look for the txid in
the result: present with height > 0 means confirmed, present with
height <= 0 means still in the mempool, absent means the server
doesn't know it. get_history is a plain, universally-supported Electrum
method, and "not in the list" replaces the old substring-matching
entirely - no more guessing at error wording to decide whether to
release funds. History is cached per scripthash within one pass, since
every "payout" row shares the same pool address.

New app/tx/pending_address.py factors out own_address_for (the
address derivation was previously duplicated informally inside
tx/broadcast.py's signing context) so confirmation.py and reconcile.py
share one definition instead of two that could compute different
addresses for the same row.

tests/unit/test_confirmation.py and test_reconcile.py needed real User
rows and a master-key bootstrap they didn't have before, since address
derivation is now exercised for real rather than assumed. Suite grows
from 217 to 222 tests. BUGS.md moves B-41 to Previously fixed - no
Medium-severity finding remains open.
2026-07-27 15:27:58 +02:00
davide 08c566d547 Restructure bump_fee into three phases, drop float fee math (B-40)
bump_fee issued one get_transaction per input (up to 15s each) and
then a broadcast, all with the caller's DB session held open - exactly
the pattern already fixed elsewhere for the same reason (B-18's
_trigger_payout, B-31's refresh_user). Also, _prevout_amount computed
a prevout's satoshi value via round(value_coins * 100_000_000) on a
float the server reported, in a codebase that is otherwise strictly
integer-satoshi.

bump_fee now takes a session_factory and a pending_id instead of a
live session and row, with three phases: read what's needed (the
signing key, current fee rate, raw tx) and close the session before
any network call; do the chain reads, signing and broadcast with no
session open; reopen a session only to persist the outcome.
_prevout_amount now asks for the raw (non-verbose) transaction and
reads embit's parsed TransactionOutput.value directly - already an
exact integer, no float conversion involved at all.

A pending_transaction that's no longer "pending" by the time bump_fee
actually runs (it confirmed in the meantime, a normal race) is now a
quiet no-op returning None, rather than being folded into RbfBumper's
error-logging path.

Suite grows from 214 to 217 tests. BUGS.md moves B-40 to Previously
fixed, and trims its own now-stale claim that bump_fee still depended
on verbose=True (B-41) - it no longer does.
2026-07-27 15:14:58 +02:00
davide 17c557b8a3 Meet BIP125's relay minimum on every RBF bump, and cap the fee rate (B-32)
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.
2026-07-27 11:16:20 +02:00
davideandClaude Sonnet 5 933760e948 Decouple the RBF abandon clock from the bump clock (B-27)
bump_fee (tx/broadcast.py) used to overwrite PendingTransaction.
broadcast_at on every fee bump, but reconcile.py's abandon-after-N-
hours grace period is measured from that same column. A transaction
successfully bumped every rbf_timeout_seconds (900s by default) but
never mined reset that clock before it could ever reach the 6-hour
abandon window, so it was never abandoned: its UTXOs never returned to
the user, and if it was a bet the round stayed in "closing"
indefinitely.

PendingTransaction gains a last_broadcast_at column (migration
861e76aaf34c, backfilled from broadcast_at for existing rows before
the NOT NULL constraint is applied). broadcast_at is now never
rewritten after creation, so reconcile.py's _is_due keeps measuring
from the first broadcast unchanged. bump_fee updates last_broadcast_at
instead, and should_bump now reads last_broadcast_at rather than
broadcast_at — correct, since whether another bump is due should reset
after every bump, unlike the reconciler's abandon check, which must
not.

BUGS.md moves B-27 to "Previously fixed" with the fix description; the
suite grows from 148 to 151 tests, including a direct proof that a tx
bumped a minute ago but first broadcast 7 hours ago still gets
abandoned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:39:38 +02:00
davideandClaude Opus 5 d528c5b475 Let the system recover from a broadcast that never confirms
The code treated a broadcast as final: money moved on-chain and the DB was
updated on the assumption it would either confirm or be fee-bumped until it
did. Neither is guaranteed, and every way that assumption broke was permanent
(BUGS.md B-02, B-03, B-04, B-07, B-08, B-20, B-21).

Persist before broadcasting. place_bet and request_withdrawal now write their
rows in a "building" state and commit, then broadcast, then promote to
broadcast/pending in a second commit. Before, a failure or crash between the
broadcast and the commit left the coins irreversibly spent with no trace: no
participant (so no entry in the draw), no pending row (so no RBF and no
confirmation tracking), and the UTXOs not even marked spent, so the next bet
would try to double-spend them. A refused broadcast now releases the reserved
UTXOs, restores the balance, removes the participant (or marks the withdrawal
failed), audit-logs it, and answers a translatable broadcast_failed — as 502,
since the network refused it, not the caller, where it used to be an opaque 500.

Reconcile what's in flight against the chain. New PendingTransactionReconciler
(app/tx/reconcile.py, every 120s and once at startup) asks whether each
non-terminal tx exists: present -> promote, gone -> mark failed with a reason,
release the inputs, roll the domain row back, audit-log it. Grace periods differ
by state (120s for "building", 6h for "pending", so the RBF bumper gets its
attempts first). It is deliberately biased to inaction: only a server that
positively doesn't know the tx counts as absent, and a transport failure never
abandons anything, because releasing a UTXO whose tx is actually alive would
invite a double-spend. Verified against the live server, which answers "No such
mempool or blockchain transaction" for an unknown txid.

Stop keying on a value that changes. An RBF bump changes the txid, and
_on_bet_confirmed looked the participant up by bet_txid — so a bumped bet
confirmed under a txid no participant carried, the row stayed "broadcast"
forever, and the scheduler waited on it forever: the round could never close and
the lottery stopped. Handlers now resolve by immutable ids (round_id/user_id,
withdrawal_id), and bump_fee retargets every stored txid — bet_txid,
Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid — plus records the
previous one in replaced_by_txid, which was never written at all.

One bad row no longer blocks the rest. The confirmation poller's per-tx lookup
is guarded: a txid the server can't resolve used to abort the whole pass, so
nothing confirmed again until an operator intervened. It also selects plain
columns instead of hydrating entities that outlive their session.

Tests: 6 reconciler cases including "a broken connection must not release coins";
the bet-ordering test probes committed state from an independent session during
the broadcast, and caught a real mistake in the first draft of this change (the
_pending_transaction helper still hardcoded status="pending", so rows were born
already-broadcast and would have got the 6-hour grace instead of 120s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:31:24 +02:00
davideandClaude Sonnet 5 f229f91632 Add server-push (SSE) notifications for round/bet/balance state changes
Frontend dashboards previously found out about state changes only on their
next poll tick (up to 15s, or 3s during a draw) — this adds a push channel
so updates land as soon as they happen instead.

- app/rounds/events.py: a small in-process pub/sub (RoundEventBroadcaster).
  The message carries no payload — it's just a "something changed, go
  refetch" signal, so it needs no auth and no knowledge of who's allowed to
  see what; personalization stays entirely in the existing REST endpoints.
- GET /rounds/stream: an SSE endpoint exposing that channel, with keep-alive
  comments so it survives idle periods behind a reverse proxy, and a
  defensive MAX_SUBSCRIBERS cap (well above the ~100 concurrent users
  expected) — past it, the endpoint returns 503 instead of opening a stream,
  and callers just keep working off polling.
- publish() calls added at every point that actually changes what a
  dashboard would want to know: new round opened, round status transitions
  (closing/drawing/paying_out/closed), a bet or withdrawal broadcast, any
  pending tx confirming (bet/withdrawal/payout), a deposit credited, and a
  new block tip arriving (the exact moment the "drawing" phase is waiting on).
- Caddyfile: excludes /rounds/stream from gzip encoding, since compression
  would buffer output and defeat the point of a live stream.

Single-process only by design for now (no cross-worker fan-out) and the
notification is a generic broadcast rather than a per-user channel — both
are deliberate scope decisions for the current ~100-user, single-container
deployment, not oversights. Polling is left fully in place as a fallback;
this is purely additive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:52:07 +02:00
davideandClaude Sonnet 5 30bde96b6e Move all business/round parameters into DB config, out of env entirely
RoundConfig gains round_duration_seconds, round_cooldown_seconds,
min_amount_sats, fee_rate_sat_vb and rbf_timeout_seconds (plus a
hardcoded default for the pre-existing bet_amount_sats) as column
defaults on the model itself — get_round_config no longer seeds from
Settings at all. Every call site that read these from settings
(scheduler, bets, withdrawals, rounds service/route, RBF bumper) now
reads the DB-backed RoundConfig instead.

app/config.py now holds only true env-driven infra/secrets (database
URL, Electrum connection, master key, JWT, admin token) — no business
parameter has an env var anymore, matching an explicit decision to drop
the "seed from settings" indirection entirely rather than keep a env
fallback nobody should rely on.

Migration backfills the existing round_config row via server_default
(matching the old settings defaults) then drops the default, so future
rows go through the ORM/model defaults instead of a stale constant.

Tests updated: should_bump's timeout_seconds is now required (no
settings fallback); test_scheduler.py seeds a RoundConfig row directly
instead of monkeypatching settings; test_withdrawals.py and
test_rounds_service.py use local constants mirroring the model
defaults instead of reading them off settings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:05:40 +02:00
davideandClaude Sonnet 5 fc2aadbc7e Add transaction broadcast, confirmation polling and RBF fee-bump
Shared per-user locking to serialize bet/withdrawal PSBT builds
(tx/locks.py), a confirmation poller for pending outgoing transactions,
and the timeout->fee-bump->rebroadcast loop used by bets, payouts and
withdrawals alike (tx/broadcast.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:57 +02:00