Commit Graph
9 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00
davideandClaude Sonnet 5.5 daa85cf4f0 Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
The pool normally holds exactly the round's bet outputs and the payout spent them all for
exactly the pool amount, so it had no change output: bump_fee could never accelerate it,
and a payout stuck in a mempool (never abandoned while a server knows it) kept the round in
`paying_out` and the whole lottery stopped.

- build_payout_transaction always has three outputs: winner, fee address, and a bump reserve
  of max(DUST_LIMIT_SATS, estimate_vsize(inputs, 3) * 10 sat/vB) paid to the reserve address
  m/84'/746'/0'/1/0. The reserve comes out of the platform's 30%, the network fee still out
  of the winner's 70%. Input value beyond winner share + commission (only rounds opened
  before the per-round pool address) is folded into the reserve output if >= dust, left to
  the network fee otherwise. `change_address` is replaced by `reserve_address`.
- `rounds.bump_reserve_sats` (Alembic e5a91c3d7b26, plain add_column) is written with the
  payout intent before the broadcast; /admin/rounds returns it.
- bump_fee takes the increase from the reserve output for a payout
  (_find_reducible_output; a payout already in flight with a change to the pool still falls
  back to that), never from the winner or fee-address outputs.
- payout_config_problem / rounds_can_open (RND-40): a bet amount and fee rate under which a
  round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants could not cover the network fee,
  the reserve and dust outputs opens no round (lottery_configured=false); PUT /admin/config
  refuses such a bet amount or fee rate with 422 config_not_payable, judged on the resulting
  values, since the payout reads the fee rate live.

Ambiguity resolved: the reserve output and any excess input are one output (RND-23 says one
output to the reserve address), and bump_reserve_sats records that output's initial value.
DAT-22's "abandon clears all amounts" stays with B-96.

Tests changed because the spec changed: the four test_payout_builder tests asserted the old
two/three-output shape with change to the pool and the fee address receiving the whole 30%;
the whitespace fee_address test now spells out the amounts rounds_can_open reads. New: reserve
sizing, payout with 1 and with 400 participants (inputs = outputs + fee to the satoshi),
exact commission/reserve/dust boundary, excess folded or left to the fee, a real payout bumped
out of its reserve with the other outputs byte-identical and bumped five times, the old
reserve-less shape still raising RbfError, the scheduler output addresses and recorded
amounts, payability boundaries found independently by bisection, no round / unconfigured flag
for an unpayable config, a round in progress surviving it, PUT /admin/config refusals and the
migration column. Mutation-checked: dropping the reserve lookup in bump_fee and the payability
check in rounds_can_open each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:55:51 +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 Opus 5 23d58796b6 Refuse to open a round that could not pay its winner (B-66)
fee_address has no column default, because an operator has to supply their own —
and the payout pays the 30% commission to it, so build_payout_transaction cannot
even be built without one. A fresh instance nonetheless opened rounds happily:
each took bets, confirmed them, and only then discovered it was unpayable,
wedging in "paying_out" and retrying every 60s with money already in the pool.
One manual recovery per round, until somebody noticed.

open_new_round_if_needed now checks rounds_can_open(config) alongside `paused`:
no payout address, no round. Nothing has moved yet at that point, which is the
whole difference. Same scope as pausing — a round already in progress still
closes, draws and pays out, since clearing the address mid-round is exactly the
operator slip that must not strand a live round.

Surfaced rather than silent, in the two places that matter: lottery_configured on
GET /rounds/current, which makes / show a *different* banner from the maintenance
one (telling a player "come back later" would be false — nothing is coming until
setup finishes), and a warning at the top of /admin's Parametri card, the one
screen that can fix it. rounds_can_open is where any future
would-make-a-round-unpayable prerequisite belongs, instead of being discovered at
payout time.

The test churn is the finding restated: 26 tests expected a round to open on an
instance with no payout address. Their fixtures now seed one, so each goes back to
testing what it says — several would otherwise have passed for the wrong reason,
returning None because of the missing address rather than because of the cooldown
or pause under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:13:00 +02:00
davideandClaude Opus 5 c4b2dc3ea2 Advertise the jackpot that will actually be paid (B-65)
participant_count and jackpot_sats were computed over every round_participants
row, while the draw only picks from confirmed participants and the payout only
spends their sats. So the advertised jackpot could exceed the one paid out, and a
player whose bet was later abandoned appeared in the count and then vanished
again.

Counting only confirmed rows would have fixed the arithmetic and broken something
else: the player who just bet would see neither themselves nor their money for a
whole block. So this is the same confirmed/in-flight split the balance already
exposes (balance_sats vs pending_balance_sats): participant_count and
jackpot_sats are now the confirmed, authoritative figures, and
pending_participant_count/pending_jackpot_sats/has_pending_bets report what is in
flight — inclusive figures, not deltas, matching the balance pair's convention.

/'s round card shows the confirmed numbers big and the difference as an amber
"+N in attesa" suffix, reusing .balance-pending's colour for the same "not
settled yet" meaning. The two new spans render from server data through t(), so
they carry no data-i18n and onLanguageChange() repaints them from the last
response — the one-mechanism-per-element rule. Both strings are in all 7
languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:52:10 +02:00
davideandClaude Sonnet 5 7fa26df104 Make a stalled draw wait observable (B-36)
_wait_for_next_block had no timeout, no log, and no audit entry: a
connection that stopped advancing the tip left a round silently frozen
in "drawing" with nothing in /admin to explain why. Log progress
periodically, write a draw_stalled audit entry past a threshold (a few
block-time multiples), and surface the wait via a new Round.drawing_started_at
column, exposed as draw_waiting_since in GET /rounds/current.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:12:27 +02:00
davideandClaude Opus 5 7c4e9983ea Survive a dropped Electrum connection, and fall back to other servers
A dropped connection used to hang the whole platform permanently, and three
defects composed to do it (BUGS.md B-01):

The read loop's death was invisible. When the socket closed, _read_loop broke out
and finished, but _run_once was blocked on gather() over two notification
consumers waiting on queues nobody would ever fill again — it never returned and
never raised, so the reconnect-with-backoff logic was unreachable.
client.wait_closed() now resolves when the loop ends for any reason, and
_run_once races it against the consumers and a keepalive with
asyncio.wait(FIRST_COMPLETED).

Nothing had a timeout. request() registered a future, wrote to a half-closed
socket (drain() often doesn't raise) and awaited a reply that would never come.
That hung a POST /bets *while holding the per-user lock*, and could stop the
confirmation poller for good. Every request is now bounded at 15s, and a timeout
tears the connection down rather than leaving a server that owes us a reply in
rotation.

There was no keepalive, so on a quiet instance the normal way this connection
dies is an idle-timeout drop by the server (~10 minutes for many). A server.ping
every 60s makes that observable within a minute.

listener.client is also cleared before reconnecting, so callers stop treating a
dead connection as live.

On top of the finding, the listener now rotates over a list of servers:
ELECTRUM_FALLBACK_SERVERS holds comma-separated host:port[:notls] extras, tried
after the primary. Everything the platform does goes through this one connection
— deposit credits, broadcasts, confirmations, the chain tip the draw waits on —
which made a single hardcoded server its biggest point of failure. A failed or
dropped session moves to the next server immediately and only sleeps on the
backoff once every server has had a turn, so one dead server costs one attempt
instead of an outage, while a genuinely offline network still backs off. A
malformed entry fails at startup, not during the outage when the fallback is what
you need.

Also fixes B-19: header handling refuses a height below the current tip and
applies height and hex together, since _wait_for_next_block waits for
tip_height > tip_at_close (a regression silently added a block to the draw's
wait) and that hex is the draw's entropy source, so a mismatched pair would be
worse than a stale one.

Verified in the live deployment: the log shows the endpoint list, then "Electrum
connected to santantonio.sytes.net:50002", and the connection holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:34:59 +02:00
davideandClaude Opus 5 daf66fd6bc Fix the round-open race, the advertised jackpot, and payout error handling
Round opening (B-09). open_new_round_if_needed now handles the IntegrityError
from ix_rounds_single_active (previous commit) by rolling back and using the
winner's round. Deviation from the plan in BUGS.md, which proposed making the
scheduler the only writer: that would mean the first bet after a cooldown
couldn't open a round, so both callers stay and a bounded retry was added
instead — a conflict where nothing is active yet just means the winner hadn't
committed, and a bet must not fail on that timing. get_active_round also logs
loudly if it ever sees more than one active round rather than silently picking
the newest.

The jackpot (B-11). It was participant_count * the *current* bet_amount_sats,
which overstated the pool (each stored bet is already net of that bet's network
fee) and silently rewrote the advertised jackpot of a round in progress whenever
an operator edited the bet amount. It now sums the participants' stored
bet_amount_sats. The remaining imprecision — the payout tx's own fee, deducted
from the winner's share and unknowable until the payout is built — is documented
in the code rather than promised away, since the comment there claimed exactness.

Payout (B-05, B-18). _trigger_payout is split into read / build+broadcast /
persist, so no DB session is held across a network call (on SQLite that meant
holding the write lock for two unbounded round-trips). That restructuring is also
what makes the error handling placeable: it now catches Exception around the
chain work and writes a payout_failed audit entry, where a malformed fee_address
used to raise EmbitError all the way to the scheduler's catch-all, leaving the
round stuck in paying_out with nothing recorded about why. Automatic payout retry
remains an open gap.

The scheduler also counts "building" participants as in-flight when deciding
whether a round may close, matching the two-phase bet write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:32:22 +02:00
davideandClaude Sonnet 5 6a857f0e07 Show pending-inclusive balance and per-player round outcome reliably
Balance display: place_bet/request_withdrawal spend whole UTXOs and mark
them spent at broadcast time, well before confirmation, so the confirmed-only
balance could drop by far more than the amount actually moving. Add
compute_pending_balance() (app/wallet/balance.py) to fold the unconfirmed
change from in-flight bet/withdrawal PendingTransactions back in; GET
/users/me now returns pending_balance_sats + has_pending, and the frontend
shows it colored green (settled) or amber (still pending) instead of the
confirmed-only figure.

Round outcome display: the win/lose reveal and the "pagamento al vincitore
in corso" status were fighting over the same UI slot, and the reveal broke
across a page refresh. Now:
- The round-status box (generic phase progress) and the personal win/lose
  box are independent and can both be visible at once.
- The win/lose box only renders for users who actually played in that round
  (new user_played field on GET /rounds/current, via a new optional-auth
  dependency so the endpoint stays usable logged-out).
- The reveal delay is anchored to the round's server-provided closes_at
  instead of a client-side "first seen" timestamp, so repeated reloads can't
  reset it, and the revealed result is persisted in localStorage so it
  survives a refresh even after the round has fully closed.
- GET /users/me/last-round-result is a durable DB-backed backstop for
  players who miss the live window entirely (backgrounded tab, offline).

Also hardens the frontend polling loop: call() now times out instead of
hanging forever, and a session-epoch counter stops an in-flight request from
a previous login from resurrecting a duplicate poll loop after logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:09:12 +02:00