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