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,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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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