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:
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user