From f1261584ff831a50237f2b82172eba94056f6757 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 21 Jul 2026 10:25:29 +0200 Subject: [PATCH] Add wallet key derivation and PSBT building BIP84 derivation of per-user P2WPKH addresses and the pool address from the encrypted master xprv (app/wallet/hd.py, keystore.py), the PLM mainnet chain params (plm_network.py), and PSBT construction for bets/ payouts/withdrawals with change-output and fee-estimation logic (psbt_builder.py). Co-Authored-By: Claude Sonnet 5 --- app/wallet/__init__.py | 0 app/wallet/balance.py | 16 +++ app/wallet/hd.py | 52 +++++++++ app/wallet/keystore.py | 11 ++ app/wallet/plm_network.py | 28 +++++ app/wallet/psbt_builder.py | 181 ++++++++++++++++++++++++++++++ tests/unit/test_hd.py | 21 ++++ tests/unit/test_payout_builder.py | 100 +++++++++++++++++ tests/unit/test_psbt_builder.py | 113 +++++++++++++++++++ 9 files changed, 522 insertions(+) create mode 100644 app/wallet/__init__.py create mode 100644 app/wallet/balance.py create mode 100644 app/wallet/hd.py create mode 100644 app/wallet/keystore.py create mode 100644 app/wallet/plm_network.py create mode 100644 app/wallet/psbt_builder.py create mode 100644 tests/unit/test_hd.py create mode 100644 tests/unit/test_payout_builder.py create mode 100644 tests/unit/test_psbt_builder.py diff --git a/app/wallet/__init__.py b/app/wallet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/wallet/balance.py b/app/wallet/balance.py new file mode 100644 index 0000000..7b0fd4b --- /dev/null +++ b/app/wallet/balance.py @@ -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 diff --git a/app/wallet/hd.py b/app/wallet/hd.py new file mode 100644 index 0000000..b752f64 --- /dev/null +++ b/app/wallet/hd.py @@ -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) diff --git a/app/wallet/keystore.py b/app/wallet/keystore.py new file mode 100644 index 0000000..d013713 --- /dev/null +++ b/app/wallet/keystore.py @@ -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() diff --git a/app/wallet/plm_network.py b/app/wallet/plm_network.py new file mode 100644 index 0000000..499feec --- /dev/null +++ b/app/wallet/plm_network.py @@ -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" diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py new file mode 100644 index 0000000..251a8fe --- /dev/null +++ b/app/wallet/psbt_builder.py @@ -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, + ) diff --git a/tests/unit/test_hd.py b/tests/unit/test_hd.py new file mode 100644 index 0000000..18a1143 --- /dev/null +++ b/tests/unit/test_hd.py @@ -0,0 +1,21 @@ +from embit.bip32 import HDKey + +from app.wallet.plm_network import PLM_MAINNET + + +def test_p2wpkh_address_uses_plm_hrp(): + from embit import script + + root = HDKey.from_seed(b"\x01" * 32, version=PLM_MAINNET["xprv"]) + child = root.derive("m/84h/746h/0h/0/0") + address = script.p2wpkh(child.to_public()).address(network=PLM_MAINNET) + assert address.startswith("plm1q") + + +def test_derivation_is_deterministic(): + root = HDKey.from_seed(b"\x02" * 32, version=PLM_MAINNET["xprv"]) + a = root.derive("m/84h/746h/0h/0/5").sec() + b = root.derive("m/84h/746h/0h/0/5").sec() + c = root.derive("m/84h/746h/0h/0/6").sec() + assert a == b + assert a != c diff --git a/tests/unit/test_payout_builder.py b/tests/unit/test_payout_builder.py new file mode 100644 index 0000000..6d2dcae --- /dev/null +++ b/tests/unit/test_payout_builder.py @@ -0,0 +1,100 @@ +import pytest +from embit import script +from embit.bip32 import HDKey +from embit.transaction import Transaction + +from app.wallet.plm_network import PLM_MAINNET +from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize + + +def _key(seed_byte: int) -> HDKey: + root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"]) + return root.derive("m/84h/746h/0h/0/0") + + +def test_payout_deducts_fee_only_from_winner_share(): + pool_key = _key(10) + pool_script = script.p2wpkh(pool_key.to_public()) + pool_address = pool_script.address(network=PLM_MAINNET) + winner_address = script.p2wpkh(_key(11).to_public()).address(network=PLM_MAINNET) + fee_address = script.p2wpkh(_key(12).to_public()).address(network=PLM_MAINNET) + + pool_amount = 10_000_000_000 # 100 PLM pot + winner_share = pool_amount * 70 // 100 + commission = pool_amount - winner_share + + utxos = [Utxo("aa" * 32, 0, pool_amount)] + built = build_payout_transaction( + signing_key=pool_key, + from_script=pool_script, + utxos=utxos, + winner_address=winner_address, + winner_share_sats=winner_share, + fee_address=fee_address, + commission_sats=commission, + change_address=pool_address, + fee_rate_sat_vb=1, + ) + + fee = estimate_vsize(1, 3) + assert built.fee_sats == fee + assert built.winner_sats == winner_share - fee + assert built.commission_sats == commission # untouched by the fee + assert built.change_sats == pool_amount - (winner_share + commission) + + parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) + assert len(parsed.vout) == 2 # no change needed: winner_share + commission == pool_amount exactly + amounts = sorted(o.value for o in parsed.vout) + assert amounts == sorted([built.winner_sats, built.commission_sats]) + + +def test_payout_adds_change_output_when_pool_utxos_exceed_target(): + pool_key = _key(20) + pool_script = script.p2wpkh(pool_key.to_public()) + pool_address = pool_script.address(network=PLM_MAINNET) + winner_address = script.p2wpkh(_key(21).to_public()).address(network=PLM_MAINNET) + fee_address = script.p2wpkh(_key(22).to_public()).address(network=PLM_MAINNET) + + winner_share = 700_000_000 + commission = 300_000_000 + utxos = [Utxo("bb" * 32, 0, 2_000_000_000)] # more than winner_share+commission + built = build_payout_transaction( + signing_key=pool_key, + from_script=pool_script, + utxos=utxos, + winner_address=winner_address, + winner_share_sats=winner_share, + fee_address=fee_address, + commission_sats=commission, + change_address=pool_address, + fee_rate_sat_vb=1, + ) + assert built.change_sats == 2_000_000_000 - (winner_share + commission) + + parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) + assert len(parsed.vout) == 3 + + +def test_payout_raises_when_winner_share_too_small(): + pool_key = _key(30) + pool_script = script.p2wpkh(pool_key.to_public()) + pool_address = pool_script.address(network=PLM_MAINNET) + winner_address = script.p2wpkh(_key(31).to_public()).address(network=PLM_MAINNET) + fee_address = script.p2wpkh(_key(32).to_public()).address(network=PLM_MAINNET) + + winner_share = 100 # smaller than the ~172 sat fee at 1 sat/vB for 1-in-3-out + commission = 50 + assert winner_share < estimate_vsize(1, 3) + utxos = [Utxo("cc" * 32, 0, winner_share + commission)] + with pytest.raises(InsufficientFundsError): + build_payout_transaction( + signing_key=pool_key, + from_script=pool_script, + utxos=utxos, + winner_address=winner_address, + winner_share_sats=winner_share, + fee_address=fee_address, + commission_sats=commission, + change_address=pool_address, + fee_rate_sat_vb=1, + ) diff --git a/tests/unit/test_psbt_builder.py b/tests/unit/test_psbt_builder.py new file mode 100644 index 0000000..7f8d34b --- /dev/null +++ b/tests/unit/test_psbt_builder.py @@ -0,0 +1,113 @@ +import pytest +from embit import script +from embit.bip32 import HDKey + +from app.wallet.plm_network import PLM_MAINNET +from app.wallet.psbt_builder import ( + InsufficientFundsError, + Utxo, + build_signed_transaction, + estimate_vsize, + select_utxos, +) + + +def _key(seed_byte: int) -> HDKey: + root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"]) + return root.derive("m/84h/746h/0h/0/0") + + +def test_estimate_vsize_grows_with_inputs_and_outputs(): + assert estimate_vsize(1, 2) < estimate_vsize(2, 2) + assert estimate_vsize(1, 1) < estimate_vsize(1, 2) + + +def test_select_utxos_picks_largest_first(): + utxos = [Utxo("a" * 64, 0, 5_000_000), Utxo("b" * 64, 0, 20_000_000), Utxo("c" * 64, 0, 1_000_000)] + selected, total = select_utxos(utxos, target_sats=10_000_000) + assert selected == [utxos[1]] # the 20M UTXO alone covers 10M + assert total == 20_000_000 + + +def test_select_utxos_raises_when_insufficient(): + utxos = [Utxo("a" * 64, 0, 1_000_000)] + with pytest.raises(InsufficientFundsError): + select_utxos(utxos, target_sats=10_000_000) + + +def test_build_signed_transaction_deducts_fee_from_amount_not_change(): + signer = _key(1) + from_script = script.p2wpkh(signer.to_public()) + my_address = from_script.address(network=PLM_MAINNET) + to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET) + + utxos = [Utxo("11" * 32, 0, 150_000_000)] + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=utxos, + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + fee = estimate_vsize(1, 2) + assert built.fee_sats == fee + assert built.recipient_sats == 10_000_000 - fee + # change reflects the full amount_sats deducted from the sender, fee comes out + # of what the recipient gets, not out of the sender's remaining balance + assert built.change_sats == 150_000_000 - 10_000_000 + assert built.spent_utxos == utxos + assert len(built.txid) == 64 + + from embit.transaction import Transaction + + parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) + assert len(parsed.vin[0].witness.items) == 2 + assert len(parsed.vout) == 2 + + +def test_build_signed_transaction_omits_change_output_when_exact_amount(): + signer = _key(3) + from_script = script.p2wpkh(signer.to_public()) + my_address = from_script.address(network=PLM_MAINNET) + to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET) + + utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=utxos, + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + assert built.change_sats == 0 + + from embit.transaction import Transaction + + parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) + assert len(parsed.vout) == 1 + + +def test_build_signed_transaction_raises_when_amount_smaller_than_fee(): + signer = _key(5) + from_script = script.p2wpkh(signer.to_public()) + my_address = from_script.address(network=PLM_MAINNET) + to_address = script.p2wpkh(_key(6).to_public()).address(network=PLM_MAINNET) + + small_amount = 100 # smaller than the ~141 sat fee at 1 sat/vB for 1-in-2-out + assert small_amount < estimate_vsize(1, 2) + utxos = [Utxo("33" * 32, 0, small_amount)] + with pytest.raises(InsufficientFundsError): + build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=utxos, + to_address=to_address, + amount_sats=small_amount, + change_address=my_address, + fee_rate_sat_vb=1, + )