Commit Graph
6 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 0b44fe632e Distinguish a pending-only balance from a truly insufficient one (B-37)
request_withdrawal validated against confirmed UTXOs only and answered
a flat insufficient_balance even when the requested amount was covered
by the pending-inclusive balance the UI actually shows (unconfirmed
change from a recent bet/withdrawal) — contradicting what the user was
looking at on screen. Raise balance_pending_confirmation instead when
compute_pending_balance covers the amount, carrying the pending sats
in params, with its error.* string in all 7 languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:23:00 +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 Opus 5 5c9ccc0344 Reject withdrawal addresses that aren't PLM
embit's Script.from_address accepts a well-formed bech32 address from any
chain: a Bitcoin bc1... parses into a perfectly valid witness program. So a
withdrawal to a BTC address built, signed and broadcast normally on PLM, and
the funds landed on a script nobody holds the key for — silently, with no
error anywhere. A malformed address fared slightly better only in that it
crashed the request with an unhandled 500.

is_valid_plm_address checks the HRP as well as the parse, and runs first in
request_withdrawal, before a single UTXO is touched. It matches what the
withdrawal form already told the user (bech32 plm1q... only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:45:07 +02:00
davideandClaude Sonnet 5 7b3555f8eb Tie the withdrawal minimum to the bet amount instead of a separate field
RoundConfig.min_amount_sats was an independently-configurable floor that
could drift out of sync with bet_amount_sats for no real reason (deposits
never had a server-side minimum anyway). Drop the field and enforce
amount_sats >= config.bet_amount_sats directly in request_withdrawal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 19:25:15 +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 8380b80d12 Add withdrawal flow
Builds and broadcasts a user->external-address PSBT with change back to
the user's own address, fee deducted from the withdrawn amount, and
registers the confirmation handler that marks the withdrawal confirmed.
Shares the per-user lock with bets so a build never races a spend from
the same UTXO set.

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