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>
87 lines
4.0 KiB
Python
87 lines
4.0 KiB
Python
import os
|
|
|
|
from embit import script
|
|
from embit.bip32 import HDKey
|
|
from embit.ec import PrivateKey
|
|
|
|
from app.config import settings
|
|
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
|
|
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
|
|
|
|
_account_key: HDKey | None = None
|
|
|
|
|
|
def generate_master_key(overwrite: bool = False) -> None:
|
|
"""One-time ops bootstrap: create a random master seed, encrypt it, write it to
|
|
disk. Not exposed via any API endpoint — run manually before first launch."""
|
|
if os.path.exists(settings.master_key_path) and not overwrite:
|
|
raise FileExistsError(f"{settings.master_key_path} already exists")
|
|
root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"])
|
|
with open(settings.master_key_path, "wb") as f:
|
|
f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
|
|
|
|
|
|
def _load_account_key() -> HDKey:
|
|
global _account_key
|
|
if _account_key is None:
|
|
with open(settings.master_key_path, "rb") as f:
|
|
token = f.read()
|
|
root = HDKey.from_base58(decrypt_xprv(token))
|
|
_account_key = root.derive(ACCOUNT_PATH)
|
|
return _account_key
|
|
|
|
|
|
def derive_user_key(derivation_index: int) -> HDKey:
|
|
return _load_account_key().derive(f"0/{derivation_index}")
|
|
|
|
|
|
def derive_user_address(derivation_index: int) -> str:
|
|
pub = derive_user_key(derivation_index).to_public()
|
|
return script.p2wpkh(pub).address(network=PLM_MAINNET)
|
|
|
|
|
|
def derive_user_wif(derivation_index: int) -> str:
|
|
"""Exports a user's raw private key (WIF) for manual server-side intervention
|
|
(e.g. sweeping funds back to a user, or out, if something gets stuck). This is
|
|
a custodial system — the server already holds the master key this is derived
|
|
from — but callers must still treat the result as a live secret: log access,
|
|
never persist it, never return it over an unauthenticated channel."""
|
|
key = derive_user_key(derivation_index)
|
|
return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(network=PLM_MAINNET)
|
|
|
|
|
|
def derive_pool_key(round_id: int | None = None) -> HDKey:
|
|
"""A pool key. With a `round_id`, that round's own key on branch 2 of the account
|
|
(`m/84'/746'/0'/2/<round_id>`, B-80, RND-35): its bets are paid to it and its
|
|
payout is signed with it, so one round's money and history never mix with another's.
|
|
Without one, the **reserve** key at branch 1, index 0 — the single pool address every
|
|
round used before B-80, still the pool of the rounds opened back then and the
|
|
destination of the payout's bump reserve. Branch 0 is users. Not a spec requirement
|
|
of the coin, an implementation choice to keep every key under the one encrypted
|
|
master key rather than in separate secrets."""
|
|
if round_id is None:
|
|
return _load_account_key().derive("1/0")
|
|
if round_id < 1:
|
|
raise ValueError(f"invalid round id {round_id}")
|
|
return _load_account_key().derive(f"2/{round_id}")
|
|
|
|
|
|
def derive_pool_address(round_id: int | None = None) -> str:
|
|
"""The pool address of a round (B-80), or the reserve address without a `round_id`."""
|
|
pub = derive_pool_key(round_id).to_public()
|
|
return script.p2wpkh(pub).address(network=PLM_MAINNET)
|
|
|
|
|
|
def derive_round_pool_key(round_id: int, pool_address: str) -> HDKey:
|
|
"""The key that controls `pool_address`, the address stored on round `round_id`.
|
|
|
|
A round opened before B-80 stores the reserve address and is signed with the reserve
|
|
key; every other round with its own. Checking the derived address against the stored
|
|
one means a key can never be used for money it does not control, and a mismatch (a
|
|
different master key, a corrupted row) fails loudly instead of building a transaction
|
|
that spends someone else's coins."""
|
|
for key in (derive_pool_key(round_id), derive_pool_key()):
|
|
if script.p2wpkh(key.to_public()).address(network=PLM_MAINNET) == pool_address:
|
|
return key
|
|
raise ValueError(f"round {round_id}: no pool key controls the stored address {pool_address!r}")
|