Implement full MVP: auth, HD wallet, Electrum client, deposits, bets, round/draw engine, payout, withdrawals, RBF, admin+audit

All 10 build-order stages complete and unit-tested (49 tests). Verified live on
mainnet: registration/address derivation, deposit crediting, a real 10 PLM bet
(broadcast + confirmed + change credited). A full round close->draw->payout
cycle was triggered live and was in progress at commit time. Withdrawal and RBF
bump are unit-tested but not yet exercised against a live broadcast. Known gaps
(scheduler doesn't resume mid-flight rounds after restart, payout has no retry,
no deployment setup, etc.) are documented in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:52:20 +02:00
co-authored by Claude Sonnet 5
parent bae48c46dc
commit df72367f02
76 changed files with 3506 additions and 1 deletions
View File
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User, UtxoEvent
async def recompute_balance(session: AsyncSession, user_id: int) -> int:
"""Source of truth: sum of this user's confirmed, unspent UTXOs. Updates and
returns the read-cache column (User.cached_balance_sats). Must be called
within the same transaction as whatever inserted/updated utxo_events rows."""
balance = await session.scalar(
select(func.sum(UtxoEvent.amount_sats)).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
)
user = await session.get(User, user_id)
user.cached_balance_sats = balance or 0
return user.cached_balance_sats
+52
View File
@@ -0,0 +1,52 @@
import os
from embit import script
from embit.bip32 import HDKey
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_pool_key() -> HDKey:
"""The "indirizzo padre" from the flowchart: all bets are sent here, and
payouts are signed with this key. Reserved on branch 1 of the account (branch 0
is user addresses), index 0 — not a spec requirement, an implementation choice
to keep it in the same encrypted master key rather than a separate secret."""
return _load_account_key().derive("1/0")
def derive_pool_address() -> str:
pub = derive_pool_key().to_public()
return script.p2wpkh(pub).address(network=PLM_MAINNET)
+11
View File
@@ -0,0 +1,11 @@
from cryptography.fernet import Fernet
from app.config import settings
def encrypt_xprv(xprv_base58: str) -> bytes:
return Fernet(settings.xprv_encryption_key).encrypt(xprv_base58.encode())
def decrypt_xprv(token: bytes) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token).decode()
+28
View File
@@ -0,0 +1,28 @@
"""PLM mainnet params for embit, verified against PalladiumWallet/src/Core/Chain/ChainProfiles.cs.
Threaded explicitly through every embit call via `network=PLM_MAINNET` rather than
registered globally, since this is a long-lived async server (embit has no
concept of "current network" beyond what you pass in).
"""
PLM_MAINNET = {
"name": "PLM Mainnet",
"wif": bytes([0x80]),
"p2pkh": bytes([55]),
"p2sh": bytes([5]),
"bech32": "plm",
"xprv": bytes.fromhex("0488ade4"),
"xpub": bytes.fromhex("0488b21e"),
"yprv": bytes.fromhex("049d7878"),
"ypub": bytes.fromhex("049d7cb2"),
"zprv": bytes.fromhex("04b2430c"),
"zpub": bytes.fromhex("04b24746"),
"Yprv": bytes.fromhex("0295b005"),
"Ypub": bytes.fromhex("0295b43f"),
"Zprv": bytes.fromhex("02aa7a99"),
"Zpub": bytes.fromhex("02aa7ed3"),
"bip32": 0,
}
BIP44_COIN_TYPE = 746
ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h"
+181
View File
@@ -0,0 +1,181 @@
from dataclasses import dataclass
from embit import script
from embit.bip32 import HDKey
from embit.finalizer import finalize_psbt
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
# Standard P2WPKH size estimates (vbytes): 10.5-byte overhead (version+counts+locktime+
# segwit marker/flag), ~68 vbytes per input, ~31 vbytes per output. Used to size the fee
# before signing (fee only needs to be "minimized ~1 sat/vB", not maximally precise).
_TX_OVERHEAD_VBYTES = 11
_P2WPKH_INPUT_VBYTES = 68
_P2WPKH_OUTPUT_VBYTES = 31
# BIP125 opt-in RBF: any sequence < 0xfffffffe signals replaceability. Set on every
# input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
RBF_SEQUENCE = 0xFFFFFFFD
class InsufficientFundsError(Exception):
pass
@dataclass
class Utxo:
txid: str
vout: int
amount_sats: int
@dataclass
class BuiltTransaction:
raw_hex: str
txid: str
fee_sats: int
recipient_sats: int
change_sats: int
spent_utxos: list[Utxo]
def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering
target_sats — the amount deducted from the sender's balance. The fee is paid
out of target_sats (see build_signed_transaction), not added on top of it."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
selected.append(utxo)
total += utxo.amount_sats
if total >= target_sats:
return selected, total
raise InsufficientFundsError("not enough confirmed balance to cover amount")
def build_signed_transaction(
*,
signing_key: HDKey,
from_script: script.Script,
utxos: list[Utxo],
to_address: str,
amount_sats: int,
change_address: str,
fee_rate_sat_vb: int,
) -> BuiltTransaction:
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
back to change_address.
`amount_sats` is deducted from the sender's balance in full: the recipient
receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender.
"""
selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
recipient_amount = amount_sats - fee
if recipient_amount <= 0:
raise InsufficientFundsError("amount too small to cover the network fee")
change = total_in - amount_sats
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
# embit reverses it internally when serializing to wire format.
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [TransactionOutput(recipient_amount, script.Script.from_address(to_address))]
if change > 0:
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, utxo in enumerate(selected):
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
signed_count = psbt.sign_with(signing_key)
if signed_count != len(selected):
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize PSBT")
raw = final_tx.serialize()
return BuiltTransaction(
raw_hex=raw.hex(),
txid=final_tx.txid().hex(),
fee_sats=fee,
recipient_sats=recipient_amount,
change_sats=change,
spent_utxos=selected,
)
@dataclass
class PayoutTransaction:
raw_hex: str
txid: str
fee_sats: int
winner_sats: int
commission_sats: int
change_sats: int
spent_utxos: list[Utxo]
def build_payout_transaction(
*,
signing_key: HDKey,
from_script: script.Script,
utxos: list[Utxo],
winner_address: str,
winner_share_sats: int,
fee_address: str,
commission_sats: int,
change_address: str,
fee_rate_sat_vb: int,
) -> PayoutTransaction:
"""Build, sign and finalize the round payout: pool -> winner + fee address,
with change back to the pool itself. Per spec, only the winner's share
absorbs the tx fee — the commission (fee_address) output is untouched."""
target = winner_share_sats + commission_sats
selected, total_in = select_utxos(utxos, target)
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
winner_amount = winner_share_sats - fee
if winner_amount <= 0:
raise InsufficientFundsError("winner share too small to cover the network fee")
change = total_in - target
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [
TransactionOutput(winner_amount, script.Script.from_address(winner_address)),
TransactionOutput(commission_sats, script.Script.from_address(fee_address)),
]
if change > 0:
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, utxo in enumerate(selected):
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
signed_count = psbt.sign_with(signing_key)
if signed_count != len(selected):
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize PSBT")
raw = final_tx.serialize()
return PayoutTransaction(
raw_hex=raw.hex(),
txid=final_tx.txid().hex(),
fee_sats=fee,
winner_sats=winner_amount,
commission_sats=commission_sats,
change_sats=change,
spent_utxos=selected,
)