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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:25:29 +02:00
co-authored by Claude Sonnet 5
parent d2db762d96
commit f1261584ff
9 changed files with 522 additions and 0 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,
)
+21
View File
@@ -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
+100
View File
@@ -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,
)
+113
View File
@@ -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,
)