Files
plm-lottery/migrations/versions/477bddae1a84_add_pool_address_to_rounds.py
T
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

56 lines
2.1 KiB
Python

"""add pool_address to rounds
Revision ID: 477bddae1a84
Revises: 283844a44b4a
Create Date: 2026-10-14 12:28:11.529107
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '477bddae1a84'
down_revision: Union[str, Sequence[str], None] = '283844a44b4a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'"
def upgrade() -> None:
"""Upgrade schema.
B-80 (DAT-60): every round gets its own pool address. Rounds that already exist were
all paid to the one shared pool address, the reserve address m/84'/746'/0'/1/0, so
that is what they are backfilled with and what their payout keeps spending.
The column is added with add_column, never batch_alter_table: on SQLite the latter
rebuilds the table and silently drops ix_rounds_single_active (B-97, DAT-56). The
server default is only there to let NOT NULL be added to a table that has rows.
"""
op.add_column("rounds", sa.Column("pool_address", sa.String(length=128), nullable=False, server_default=""))
connection = op.get_bind()
has_rounds = connection.execute(sa.text("SELECT COUNT(*) FROM rounds")).scalar()
if has_rounds:
# Derived only when there is something to backfill, so a fresh database (and the
# migration test) needs no master key. Imported here for the same reason.
from app.wallet.hd import derive_pool_address
connection.execute(
sa.text("UPDATE rounds SET pool_address = :address WHERE pool_address = ''"),
{"address": derive_pool_address()},
)
def downgrade() -> None:
"""Downgrade schema."""
# drop_column on SQLite is a table rebuild too, which loses the expression index:
# recreate it (B-97).
op.drop_column("rounds", "pool_address")
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")