18 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 9520c80dcf Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)
- bet/withdrawal routes read listener.client after taking the user lock
- a reduced full-balance withdrawal is checked against the minimum again
- a bet covered only by pending balance answers balance_pending_confirmation
- a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent
- an abandoned payout clears winner/fee/reserve amounts with payout_txid
- the reconciler pings SSE only for rows it changed
- closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current
- one refresh per user at a time; a failed refresh no longer ends the Electrum session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:08:14 +02:00
davideandClaude Sonnet 5.5 54dbffb552 Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a
private chain: the bet left the chain but its participant stayed `confirmed`, and the payout
failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs
were also credited at 1 confirmation (maturity is 120).

Deposits
- `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the
  threshold for a deposit; an output of a platform transaction (any txid of a
  pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120,
  recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached).
  `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no
  session is open across the lookup.
- The quorum must also place the coin deep enough on our own tip (ELC-17).
- Confirmed outputs short of their threshold are pending deposits (memory only, replaced at
  every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal
  message), never spendable. No scripthash notification announces a maturing deposit, so each
  new block refreshes only the users that have one, one task per user (DEP-09 now says so).

Payout
- `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other
  servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a
  mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints
  (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool
  verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one
  `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same
  transaction, and the 70/30 split is computed on what is left.
- Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93,
  with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with
  `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears
  the mark and the next attempt verifies again. /admin/rounds returns it.
- Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server
  `listunspent` choice, since that address holds no single round's bets.

Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every
participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to
repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says
"1 confirmation" (7 languages, same keys); guides updated.

Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0
now set a tip past every threshold; the payout fixtures seed confirmed participants (the new
source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the
verification, and test_round_pool_address asserts the quorum is asked about the round's
address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching
or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and
disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with
the split recomputed, winner gone held and paused, held payouts untouched until the resume,
re-verification after it, no quorum, a payout with 1 and with 400 participants against
reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored
coinbase, no height bound, no verification and no winner hold each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:25:41 +02:00
davideandClaude Sonnet 5.5 a4b160e240 Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.

- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
  the history of the row's address and looks for every txid of the row. Present on any
  server -> promoted (adopting the version found). Absent is only "no server of the
  quorum knows it" and needs the active server plus the quorum minimum of the others to
  have answered; even then nothing is released without proof:
  (a) an input spent in a block by a transaction that is none of the row's versions,
      agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
  (b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
      refused by all of them, explicitly, for missing/spent inputs.
  Accepted or "already known" by any server means alive; a fee refusal is not proof
  (`building` -> `pending` for the bumper, `pending` untouched); too few answers or
  anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
  a 14-day watch: a failed row that a majority of the other servers (at least the quorum
  minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
  pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
  The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
  from POST /admin/pause (which accepts an optional `note` and never overwrites an
  automatic reason), cleared by resume, which logs the reason it lifted; exposed on
  /admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
  system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
  but never abandon.

Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:07:09 +02:00
davideandClaude Sonnet 5.5 f936d6660c Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
The poller and the reconciler matched current_txid only. If an earlier version of
a bumped transaction confirmed it was never seen, and after 6h the row was abandoned:
a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid payout
rebuilt. bump_fee also broadcast in phase 2 and persisted in phase 3 unconditionally,
so a crash or a `database is locked` in between left a transaction on the network that
the DB did not know, and an abandon or a confirmation in the meantime was overwritten.

- New table pending_transaction_txids (migration a3f5c2d9b7e1; txid unique, index on
  the owning row; existing rows backfilled with current_txid + raw and the txid their
  last bump replaced, raw_hex empty). Written with the row by place_bet,
  request_withdrawal and _trigger_payout; removed with a bet row that a verified
  refusal deletes (DAT-38).
- bump_fee in four phases: read, build and sign, commit the replacement as a candidate
  version, broadcast (verdict of B-76, fanned out when accepted; an explicit refusal
  raises RbfError and leaves the row), then switch the row over with
  `UPDATE ... WHERE status='pending' AND current_txid=<replaced>`. Zero rows matched is
  a logged conflict: nothing is overwritten and the candidate stays recorded. An
  uncertain broadcast adopts the replacement. A retry rebuilds the same candidate
  without tripping the unique txid.
- poll_once and reconcile_once look for every txid of a row. When the version found is
  not the current one, broadcast.adopt_version makes it current (raw tx included) and
  retargets spent_txid, bet_txid, withdrawals.txid and payout_txid as a bump does,
  before the confirmation handler runs. _release_inputs frees inputs claimed by any
  version of the row.
- RbfBumper takes the listener as `network`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:46:43 +02:00
davideandClaude Sonnet 5.5 2b6ddb019b Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.

- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
  carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
  ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
  uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
  blockchain.transaction.get of the active server and every other one (10s each) —
  any server returning the tx means accepted; the active server plus
  MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
  the reduced rule); too few answers is uncertain. "already known" wording is only a
  shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
  releases; uncertain leaves the rows building for the reconciler, publishes SSE and
  POST /bets and POST /withdrawals answer 202 with status building; "already known"
  promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
  (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
  replacement in bump_fee is left for B-77, which rewrites that function.

Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:22:20 +02:00
davideandClaude Sonnet 5.5 fd6a91e93d Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history
grew forever, other rounds' money and stray coins mixed with a round's own, and
the payout picked from everything it held.

- hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2
  (m/84'/746'/0'/2/<round_id>); no argument is the reserve address 1/0.
  derive_round_pool_key checks the key against the address stored on the round,
  so a payout can never be signed for money its key does not control.
- rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with
  the reserve address since that is where their bets went; add_column, not a
  batch alter, so the expression indexes stay): written in the transaction that
  inserts the round, once its id exists.
- place_bet pays round.pool_address; the payout reads, signs and takes change at
  the round's own address; own_address_for and bump_fee's _signing_context follow
  the round (confirmation poller and reconciler pass round_id).
- Withdrawals to any round pool address, the reserve address or fee_address are
  refused (withdrawal_to_platform_address, 400, all seven languages).
- GET /rounds/current and GET /admin/rounds expose pool_address.
- RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every
  confirmed coin at the round's address, not only the participants' outpoints
  (participants carry no bet_vout yet).
- tests/unit/conftest.py: every test now gets a throwaway master key. Opening a
  round derives an address, and tests that merely opened a round were silently
  reading the real key from the local .env.
- Existing tests changed because the spec changed: payout fixtures give their
  round a pool_address; the reconciler's address-cache test uses two payouts of
  one round (rows of different rounds no longer share an address);
  own_address_for now takes the round id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:52:32 +02:00
davideandClaude Sonnet 5.5 7ba8e22bb4 Real SQLite transactions: BEGIN IMMEDIATE and synchronous=FULL (B-88, DAT-74, DAT-52, ARC-07, TX-28, BET-11, DAT-16)
pysqlite's legacy mode emitted BEGIN only at the first DML, so every read
before it ran outside any transaction and a lock wait surfaced as "database is
locked" at an arbitrary later statement; place_bet also mistook that error for
a write-snapshot conflict, which SQLite cannot produce.

- _register_sqlite_pragmas: synchronous=FULL, isolation_level=None and a
  `begin` event that emits BEGIN / BEGIN IMMEDIATE.
- begin_immediate(session): takes the write lock before the first read; called
  first thing in place_bet, request_withdrawal, the scheduler transitions,
  _trigger_payout, the confirmation poller, the reconciler, bump_fee's persist
  phase, deposit crediting / external-spend marking / reinstating, and the
  derivation-index allocation at registration.
- A lock not granted before a broadcast answers service_busy (503, all seven
  languages), nothing built or sent; after a broadcast nothing is undone, the
  rows stay `building` for the reconciler.
- Spec, storico, BUGS.md and CLAUDE.md updated; 37 new tests on a real file DB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 16:26:01 +02:00
davideandClaude Opus 5 8dd913ec59 Always keep a change output, so every tx stays fee-bumpable (B-62)
The max-amount checkbox sends amount_sats == the whole confirmed balance, so
change came out at 0, the change output was dropped, and the transaction had a
single output. bump_fee has nothing to shrink there: it raised RbfError every
30s until the reconciler abandoned the row six hours later. The RBF
single-change-output limitation was a documented gap, but the UI made it the
*default* withdrawal path.

The extra-input fallback would not have helped this case: a transaction moving
the entire balance already spends every UTXO the sender has. So the fix is at
build time — build_signed_transaction never produces a change output below
DUST_LIMIT_SATS, and never folds it into the fee either:

- withdrawals pass reduce_amount_to_keep_change=True and move a dust limit less.
  The fee already comes out of the withdrawn amount by design, so this is the
  same rule applied a little harder, and Withdrawal.amount_requested_sats vs
  amount_sent_sats already existed to record the difference.
- bets don't: the bet is a fixed price that can't be quietly reduced. A balance
  exactly equal to the bet is refused with balance_leaves_no_change (translated
  into all 7 languages, carrying required_extra_sats), which turns "a user's
  balance must never exactly equal the bet" from a documented assumption into an
  enforced one — and stops an unbumpable bet from holding a round open until the
  reconciler gives up on it.

bump_fee's no-change guard stays: a single-output tx broadcast before this
change can still be pending across the deploy, and it must fail loudly rather
than start shrinking a recipient's output. Its test now hand-builds that shape,
precisely because the builder no longer will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:36:55 +02:00
davide e7f844b11f Publish an SSE update from the bet/withdrawal rollback paths (B-49)
_release_failed_bet and _release_failed_withdrawal restored the balance, freed
the reserved UTXOs and (for a bet) removed the participant without calling
broadcaster.publish(), so every dashboard kept showing the phantom bet and the
reduced balance until its next poll — while the success path and the
reconciler's own abandon path both published.

The two regression tests pre-open the round before subscribing: place_bet opens
one itself, and that publish() would otherwise satisfy the assertion whether or
not the rollback published anything.
2026-07-27 23:35:10 +02:00
davide 4c80c1c5bf Cap the number of inputs a transaction may spend (B-48)
select_utxos had no ceiling on input count, so an address fragmented into many
small deposits built an ever-larger transaction whose fee — deducted from the
amount being moved — eroded the bet's share of the pool or the withdrawn amount,
and past a few hundred inputs stopped being standard at all.

MAX_TX_INPUTS (50) now bounds the selection. Reaching the cap without covering
the target is reported as its own "too_many_inputs" code, distinct from having
no funds, with the cap carried in the error params for the 7 translations. The
payout path records the same distinction in its payout_failed audit reason.
2026-07-27 23:30:06 +02:00
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 Opus 5 0cf35147ad Answer user-facing API failures with a machine-readable error code
The dashboard now speaks seven languages but every failure path still showed
the API's raw English text ("insufficient balance", "current password is
incorrect"), which is the most frequent and least forgiving part of the UI to
leave untranslated.

Rather than teach the API about locales, it keeps answering in one language
and hands the client something to translate: `detail` becomes
{code, message, params}, where message stays English for non-dashboard
consumers (curl, tests) and code maps onto `error.<code>` in i18n.js. An
unknown code falls back to message, so a client older or newer than the server
degrades to English instead of a blank toast.

Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and
carry the code from where the failure actually happens; str(exc) is still the
English message, so existing tests keep matching on it. Interpolated values
travel in params rather than baked into the English sentence — amounts as
*_sats, from which the frontend derives a *_plm sibling, so each language can
place them wherever its grammar wants.

admin.js reads detail.message defensively: the admin endpoints still return a
bare string, but the shared auth dependencies now return the structured form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:44:39 +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 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