mirror of
https://github.com/bitromortac/lndmanage.git
synced 2026-08-13 12:33:37 +02:00
Merge pull request #137 from bitromortac/2207-batchopen
openchannels: replace with lnd batchopen
This commit is contained in:
commit
12edf7c4ef
7 changed files with 64 additions and 712 deletions
26
README.md
26
README.md
|
|
@ -7,6 +7,8 @@
|
|||
lndmanage is a command line tool for advanced channel management of an
|
||||
[LND](https://github.com/lightningnetwork/lnd) node.
|
||||
|
||||
**DISCLAIMER: This is BETA software, so please be careful. No warranty is given.**
|
||||
|
||||
[See installation instructions.](#setup)
|
||||
|
||||
### Feature list:
|
||||
|
|
@ -28,10 +30,6 @@ lndmanage is a command line tool for advanced channel management of an
|
|||
* Batched channel opening [```openchannels```](#batched-channel-opening)
|
||||
* Support of [```lncli```](#lncli-support)
|
||||
|
||||
**DISCLAIMER: This is BETA software, so please be careful (All actions are
|
||||
executed as a dry run unless you call lndmanage with the ```--reckless```
|
||||
flag though). No warranty is given.**
|
||||
|
||||
## Command Line Options
|
||||
```
|
||||
usage: lndmanage.py [-h] [--loglevel {INFO,DEBUG}] {status,listchannels,rebalance,circle,recommend-nodes,report,info,lncli,openchannels,update-fees} ...
|
||||
|
|
@ -405,24 +403,8 @@ under the `annotations` section (as specified in
|
|||
can be saved. These annotations will then appear in the `listchannels` views.
|
||||
|
||||
## Batched Channel Opening
|
||||
lndmanage supports batched channel opening support using LND's internal wallet.
|
||||
With this command you can specify node pubkeys, amounts, and have coin control
|
||||
to avoid change creation. Reserves for anchor commitments are respected or
|
||||
created automatically.
|
||||
|
||||
The `openchannels` command can operate in several ways. You only specify
|
||||
the node pubkeys as a minimal input and the tool will automatically connect
|
||||
to the nodes in the channel opening process. You may also specify a list
|
||||
of UTXOs which are used as the budget. The individual channel capacities
|
||||
can be given either as absolute values, or as relative values
|
||||
(they will be rescaled to the sum of UTXOs), or you can give a total amount,
|
||||
which will be distributed over the new channels.
|
||||
|
||||
Anchor commitments introduce some UX issues with reserving UTXOs that are needed
|
||||
to confirm commitment transactions in time via child-pays-for-parent. This is
|
||||
why you won't be able to spend down all funds on newer versions of LND.
|
||||
lndmanage recognizes this case and will not touch small UTXOs that are suitable
|
||||
for reserves, or it will create them.
|
||||
lndmanage supports batched channel opening wrapping LND's batchopen command. With this
|
||||
command you can specify node pubkeys, amounts or a total amount for your channels.
|
||||
|
||||
## Support of lncli
|
||||
lndmanage supports the native command line interface of `lnd` in interactive mode.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from lndmanage.lib.ln_utilities import (
|
|||
convert_channel_id_to_short_channel_id,
|
||||
local_balance_to_unbalancedness
|
||||
)
|
||||
from lndmanage.lib.psbt import extract_psbt_inputs_outputs
|
||||
from lndmanage.lib.data_types import UTXO, AddressType
|
||||
from lndmanage.lib.user import yes_no_question
|
||||
from lndmanage.lib.utilities import convert_dictionary_number_strings_to_ints
|
||||
|
|
@ -783,201 +782,36 @@ class LndNode:
|
|||
|
||||
def open_channels(self, pubkeys: List[bytes],
|
||||
amounts_sat: List[int],
|
||||
change_sat: int,
|
||||
utxos: Optional[List[UTXO]],
|
||||
sat_per_vbyte: int,
|
||||
reckless=False,
|
||||
private=False):
|
||||
"""
|
||||
Batch opens channels to other nodes.
|
||||
Needs lnd compiled with walletrpc.
|
||||
Should be used with lib.openchannels.ChannelOpener.open_channels
|
||||
to sanitize method inputs.
|
||||
private=False, test=False):
|
||||
channels = []
|
||||
|
||||
1. Construct PSBT for funding.
|
||||
2. Fund and sanity check PSBT.
|
||||
3. Verify PSBT by server.
|
||||
4. Sign PSBT.
|
||||
5. Tell server about signed PSBT.
|
||||
6. Publish funding transaction.
|
||||
7. Optionally abort funding and free locked utxos.
|
||||
"""
|
||||
# at this stage we assume that we are already connected to the nodes
|
||||
|
||||
utxo_unlocking_needed = True
|
||||
psbt_unfunded = None
|
||||
utxo_leases = []
|
||||
pending_chan_ids = []
|
||||
addresses = []
|
||||
|
||||
logger.info(">>> Asking peers for channel opening.")
|
||||
time_start = time.time()
|
||||
try:
|
||||
for n, (pk, amt) in enumerate(zip(pubkeys, amounts_sat)):
|
||||
pending_chan_id = os.urandom(32)
|
||||
pending_chan_ids.append(pending_chan_id)
|
||||
|
||||
# 1. Construct PSBT for funding.
|
||||
if n == 0: # for the first iteration we don't have a base psbt
|
||||
psbt_shim = lnd.PsbtShim(pending_chan_id=pending_chan_id, no_publish=True)
|
||||
shim = lnd.FundingShim(psbt_shim=psbt_shim)
|
||||
else:
|
||||
psbt_shim = lnd.PsbtShim(pending_chan_id=pending_chan_id, base_psbt=psbt_unfunded, no_publish=True)
|
||||
shim = lnd.FundingShim(psbt_shim=psbt_shim)
|
||||
request = lnd.OpenChannelRequest(
|
||||
node_pubkey=pk,
|
||||
local_funding_amount=amt,
|
||||
funding_shim=shim,
|
||||
private=private,
|
||||
)
|
||||
open_channel_stream = self._rpc.OpenChannel(request)
|
||||
|
||||
for chan_response in open_channel_stream:
|
||||
# first response should be that the funding psbt was created:
|
||||
if chan_response.HasField('psbt_fund'):
|
||||
logger.debug(f" > peer {pk.hex()[:10]}...: got address: {chan_response.psbt_fund.funding_address}")
|
||||
addresses.append(chan_response.psbt_fund.funding_address)
|
||||
psbt_unfunded = chan_response.psbt_fund.psbt
|
||||
break
|
||||
|
||||
# 2. Fund and sanity check PSBT.
|
||||
logger.info(f">>> Funding PSBT.")
|
||||
inputs = [
|
||||
lnd.OutPoint(
|
||||
txid_str=utxo.txid,
|
||||
output_index=utxo.output_index,
|
||||
) for utxo in utxos
|
||||
]
|
||||
outputs = [
|
||||
(addr, amount) for addr, amount in zip(addresses, amounts_sat)
|
||||
]
|
||||
if change_sat:
|
||||
change_address = self._rpc.NewAddress(lnd.NewAddressRequest(
|
||||
type=lnd.AddressType.WITNESS_PUBKEY_HASH)).address
|
||||
outputs.append((change_address, change_sat))
|
||||
|
||||
fund_psbt = self._walletrpc.FundPsbt(lndwalletkit.FundPsbtRequest(
|
||||
raw=lndwalletkit.TxTemplate(
|
||||
inputs=inputs,
|
||||
outputs=outputs,
|
||||
),
|
||||
sat_per_vbyte=sat_per_vbyte,
|
||||
logger.info(f">>> Opening channels at {sat_per_vbyte} sat per vbyte:")
|
||||
for amount, pubkey in zip(amounts_sat, pubkeys):
|
||||
logger.info(f" {pubkey.hex()}: {amount} sat")
|
||||
channels.append(lnd.BatchOpenChannel(
|
||||
node_pubkey=pubkey,
|
||||
local_funding_amount=amount,
|
||||
push_sat=0,
|
||||
private=private,
|
||||
))
|
||||
utxo_leases = fund_psbt.locked_utxos
|
||||
|
||||
# sanity checks
|
||||
if fund_psbt.change_output_index != -1:
|
||||
raise Exception("We shouldn't have an internal change output, please report this.")
|
||||
logger.info("\n>>> WARNING: This feature is new, use at your own risk. "
|
||||
"Please check the above output carefully.\n")
|
||||
logger.info("\n>>> Do you want to open the channel(s) (y/n)?")
|
||||
if not test:
|
||||
if not yes_no_question('no'):
|
||||
return
|
||||
|
||||
num_inputs, num_outputs, psbt_amounts = extract_psbt_inputs_outputs(fund_psbt.funded_psbt)
|
||||
logger.debug(f" given inputs: {utxos}")
|
||||
logger.debug(f" inputs (psbt): {num_inputs}")
|
||||
logger.debug(f" outputs (psbt): {num_outputs}")
|
||||
logger.debug(f" amounts (psbt): {psbt_amounts}")
|
||||
assert num_inputs == len(inputs)
|
||||
assert num_outputs == len(outputs)
|
||||
if change_sat:
|
||||
if change_sat in psbt_amounts:
|
||||
psbt_amounts.remove(change_sat)
|
||||
else:
|
||||
raise ValueError("Expected change, but couldn't find it.")
|
||||
# order of outputs is not clear, comparing sets
|
||||
assert set(amounts_sat) == set(psbt_amounts)
|
||||
# add back change
|
||||
psbt_amounts.append(change_sat)
|
||||
|
||||
fee = sum(utxo.amount_sat for utxo in utxos) - sum(psbt_amounts)
|
||||
assert fee < 100000, f'Fee ({fee} sat) unreasonably high? Stopping.'
|
||||
|
||||
logger.info(f">>> WARNING: this is a relatively new feature, so "
|
||||
f"please check the generated PSBT by the following command:")
|
||||
logger.info(f'bitcoin-cli decodepsbt "{str(binascii.b2a_base64(fund_psbt.funded_psbt).strip(), "utf-8")}"')
|
||||
logger.info(f">>> You have {OPEN_EXPIRY_TIME_MINUTES} minutes from now to decide.\n")
|
||||
logger.info("\n>>> Do you want to open the channel(s) (y/n)?")
|
||||
if not reckless and not yes_no_question('no'):
|
||||
raise InterruptedError("User canceled the process.")
|
||||
time_end = time.time()
|
||||
if time_end - time_start > OPEN_EXPIRY_TIME_MINUTES * 60:
|
||||
raise InterruptedError("Time expired, aborted the channel opening process.")
|
||||
|
||||
# 3. Verify PSBT by server.
|
||||
logger.info(f">>> Verifying PSBT.")
|
||||
for p in pending_chan_ids:
|
||||
response = str(self._rpc.FundingStateStep(
|
||||
lnd.FundingTransitionMsg(
|
||||
psbt_verify=lnd.FundingPsbtVerify(
|
||||
funded_psbt=fund_psbt.funded_psbt,
|
||||
pending_chan_id=p,
|
||||
),
|
||||
)
|
||||
)).strip()
|
||||
if response:
|
||||
logger.debug(response)
|
||||
|
||||
# 4. Sign PSBT.
|
||||
logger.info(f">>> Signing PSBT.")
|
||||
finalize = self._walletrpc.FinalizePsbt(
|
||||
lndwalletkit.FinalizePsbtRequest(funded_psbt=fund_psbt.funded_psbt))
|
||||
raw_final_tx = finalize.raw_final_tx
|
||||
psbt_signed = finalize.signed_psbt
|
||||
logger.info(f" Signed transaction:\n {raw_final_tx.hex()}")
|
||||
logger.info(f" Signed psbt:\n {str(binascii.b2a_base64(psbt_signed).strip(), 'utf-8')}")
|
||||
logger.info(f" Final transaction size: {len(raw_final_tx)} bytes")
|
||||
|
||||
# 5. Tell server about signed PSBT.
|
||||
for p in pending_chan_ids:
|
||||
response = str(self._rpc.FundingStateStep(
|
||||
lnd.FundingTransitionMsg(
|
||||
psbt_finalize=lnd.FundingPsbtFinalize(
|
||||
signed_psbt=psbt_signed,
|
||||
pending_chan_id=p,
|
||||
),
|
||||
)
|
||||
)).strip()
|
||||
if response:
|
||||
logger.debug(f" > Funding step response: {response}")
|
||||
|
||||
# 6. Publish funding transaction.
|
||||
logger.info(f">>> Publishing transaction.")
|
||||
self._walletrpc.PublishTransaction(lndwalletkit.Transaction(
|
||||
tx_hex=raw_final_tx,
|
||||
label='lndmanage: batch open'
|
||||
))
|
||||
utxo_unlocking_needed = False
|
||||
|
||||
except grpc.RpcError as e:
|
||||
logger.info(f"Error: {e}")
|
||||
|
||||
# 7. Optionally abort funding and free locked utxos.
|
||||
finally:
|
||||
if utxo_unlocking_needed:
|
||||
logger.info(">>> Cleaning up.")
|
||||
# cancel all funding reservations
|
||||
for p in pending_chan_ids:
|
||||
try:
|
||||
self._rpc.FundingStateStep(
|
||||
lnd.FundingTransitionMsg(
|
||||
shim_cancel=lnd.FundingShimCancel(
|
||||
pending_chan_id=p,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(e)
|
||||
# unlock coins
|
||||
for lease in utxo_leases:
|
||||
try:
|
||||
self._walletrpc.ReleaseOutput(
|
||||
lndwalletkit.ReleaseOutputRequest(
|
||||
id=lease.id,
|
||||
outpoint=lnd.OutPoint(
|
||||
txid_str=lease.outpoint.txid_str,
|
||||
output_index=lease.outpoint.output_index,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(e)
|
||||
request = lnd.BatchOpenChannelRequest(
|
||||
channels=channels,
|
||||
sat_per_vbyte=sat_per_vbyte,
|
||||
label='lndmanage: batch open',
|
||||
)
|
||||
response = self._rpc.BatchOpenChannel(request)
|
||||
logger.info(f">>> Pending channels:")
|
||||
for r in response.pending_channels:
|
||||
logger.info(f" {r.txid.hex()}:{r.output_index}")
|
||||
|
||||
def _connect_nodes(self, pubkeys: List[str]) -> List[str]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ Module for opening lightning channels in a batched way.
|
|||
"""
|
||||
from math import ceil
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from lndmanage.lib.data_types import UTXO, AddressType
|
||||
from lndmanage import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -14,169 +13,9 @@ if TYPE_CHECKING:
|
|||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
# transaction component sizes
|
||||
# parameters via github.com/virtu/libtxsize
|
||||
# ./libtxsize-cli.py -i P2SH-P2WPKH,P2WPKH -o P2WSH-2-of-2-MULTISIG,P2WPKH
|
||||
|
||||
# version, locktime, nins, nouts
|
||||
TRANSACTION_OVERHEAD_VBYTES = 8 + 1 + 1
|
||||
# inputs
|
||||
P2WPKH_INPUT_VBYTES = 41
|
||||
P2SH_P2WPKH_INPUT_VBYTES = 64
|
||||
# outputs
|
||||
P2WSH_OUTPUT_VBYTES = 43
|
||||
P2WPKH_OUTPUT_VBYTES = 31
|
||||
# witness
|
||||
P2SH_P2WPKH_INPUT_WITNESS_WEIGHT = 109
|
||||
P2WPKH_INPUT_WITNESS_WEIGHT = 109
|
||||
MARKER_FLAG_WEIGHT = 2
|
||||
WITNESS_NUM_INPUT_WEIGHT = 1
|
||||
WITNESS_SCALE = 4
|
||||
|
||||
ANCHOR_RESERVE = 10 * 10000
|
||||
MAX_ANCHOR_RESERVE = 5 * ANCHOR_RESERVE
|
||||
MIN_CHANNEL_SIZE = 500000
|
||||
WUMBO_LIMIT = 16777215
|
||||
|
||||
def calculate_fees(sat_per_vbyte: int, num_p2wkh_inputs: int, num_np2wkh_inputs,
|
||||
num_channels: int, has_change=False) -> int:
|
||||
"""Calculates the size (in vbytes) of a transaction and determines the fee."""
|
||||
# see also https://github.com/btcsuite/btcwallet/tree/master/wallet/txsizes/size.go
|
||||
# for lnd fee calculation
|
||||
size_vbytes = 0
|
||||
size_vbytes += TRANSACTION_OVERHEAD_VBYTES
|
||||
size_vbytes += P2WPKH_INPUT_VBYTES * num_p2wkh_inputs
|
||||
size_vbytes += P2SH_P2WPKH_INPUT_VBYTES * num_np2wkh_inputs
|
||||
size_vbytes += P2WSH_OUTPUT_VBYTES * num_channels
|
||||
|
||||
# due to a bug in lnd fee estimation, we always add a change output here
|
||||
size_vbytes += P2WPKH_OUTPUT_VBYTES
|
||||
|
||||
if has_change:
|
||||
size_vbytes += P2WPKH_OUTPUT_VBYTES
|
||||
|
||||
# add witness data
|
||||
witness_weight = 0
|
||||
witness_weight += MARKER_FLAG_WEIGHT
|
||||
witness_weight += WITNESS_NUM_INPUT_WEIGHT
|
||||
witness_weight += P2WPKH_INPUT_WITNESS_WEIGHT * num_p2wkh_inputs
|
||||
witness_weight += P2SH_P2WPKH_INPUT_WITNESS_WEIGHT * num_np2wkh_inputs
|
||||
|
||||
# for the total vbytes, discount witness data
|
||||
size_vbytes = size_vbytes + (witness_weight + 3) / WITNESS_SCALE
|
||||
|
||||
logger.debug(
|
||||
f" Transaction size calculation for "
|
||||
f"{num_p2wkh_inputs} (p2wkh) + {num_np2wkh_inputs} (np2wkh) inputs, {num_channels}"
|
||||
f"channels, change {has_change}: {int(size_vbytes)} vbytes"
|
||||
)
|
||||
# take an overestimation, let LND reduce fees (as LND's fee estimation is buggy)
|
||||
return int(size_vbytes * sat_per_vbyte)
|
||||
|
||||
|
||||
def count_input_types(utxos: List[UTXO]) -> Tuple[int, int]:
|
||||
"""Count the number of p2wkh and np2wkh inputs in the list of UTXOs."""
|
||||
num_p2wkh = 0
|
||||
num_npw2kh = 0
|
||||
for utxo in utxos:
|
||||
if utxo.transaction_type == AddressType.WITNESS_PUBKEY_HASH:
|
||||
num_p2wkh += 1
|
||||
elif utxo.transaction_type == AddressType.NESTED_PUBKEY_HASH:
|
||||
num_npw2kh += 1
|
||||
return num_p2wkh, num_npw2kh
|
||||
|
||||
|
||||
def provide_coins(utxos: List[UTXO], total_amount_requested: Optional[int],
|
||||
spend_all_utxos: bool, sat_per_vbyte: int, num_channels: int,
|
||||
anchor_reserve: int) -> Tuple[List[UTXO], int, int, int]:
|
||||
"""Selects coins from a list of UTXOs to spend a total amount.
|
||||
|
||||
A total amount of None indicates that we want to spend all uxtos.
|
||||
|
||||
If spend_all_utxos is true, all utxos will be included as the inputs, and
|
||||
change is accordingly created.
|
||||
The coin selection handles the reservation of anchor funds.
|
||||
|
||||
:returns
|
||||
included UTXOS,
|
||||
total budget spendable in sat,
|
||||
change amount in sat,
|
||||
fee amount in sat
|
||||
"""
|
||||
available_utxos = sorted(utxos, reverse=True) # decreasing order
|
||||
|
||||
if not total_amount_requested: # try to spend the full UTXO set
|
||||
create_anchor_output = bool(anchor_reserve)
|
||||
# Here we try to spend all funds. If we should reserve some funds for
|
||||
# anchor outputs, we try to take some already existent small UTXOs. If
|
||||
# that's not possible, we need to create a change output.
|
||||
anchor_utxos = []
|
||||
if create_anchor_output:
|
||||
# Search for viable anchor UTXOs beginning from the smallest UTXO.
|
||||
for utxo in reversed(available_utxos):
|
||||
anchor_utxos.append(utxo)
|
||||
if anchor_reserve <= sum(utxo.amount_sat for utxo in anchor_utxos) <= MAX_ANCHOR_RESERVE:
|
||||
logger.info(" Reserving UTXOs for anchors:")
|
||||
for anchor_utxo in anchor_utxos:
|
||||
logger.info(f" {str(anchor_utxo)}")
|
||||
available_utxos.remove(anchor_utxo)
|
||||
create_anchor_output = False
|
||||
break
|
||||
num_p2wkh, num_np2wkh = count_input_types(available_utxos)
|
||||
fee = calculate_fees(
|
||||
sat_per_vbyte,
|
||||
num_p2wkh_inputs=num_p2wkh,
|
||||
num_np2wkh_inputs=num_np2wkh,
|
||||
num_channels=num_channels,
|
||||
has_change=create_anchor_output
|
||||
)
|
||||
anchor_change = anchor_reserve if create_anchor_output else 0
|
||||
budget = sum(utxo.amount_sat for utxo in available_utxos) \
|
||||
- anchor_change - fee
|
||||
if budget < 0:
|
||||
raise ValueError(f"Not enough funds for channel opening.")
|
||||
change = anchor_change
|
||||
return available_utxos, budget, change, fee
|
||||
|
||||
else: # We try to spend a smaller amount than the given UTXO set.
|
||||
# We need to include change either because funds are left over
|
||||
# or we need to keep the anchor reserve.
|
||||
create_change = True
|
||||
if spend_all_utxos: # user wants to batch all inputs together
|
||||
num_p2wkh, num_np2wkh = count_input_types(utxos)
|
||||
fee = calculate_fees(
|
||||
sat_per_vbyte,
|
||||
num_p2wkh_inputs=num_p2wkh,
|
||||
num_np2wkh_inputs=num_np2wkh,
|
||||
num_channels=num_channels,
|
||||
has_change=create_change
|
||||
)
|
||||
utxo_sum = sum(utxo.amount_sat for utxo in utxos)
|
||||
budget = min(total_amount_requested, utxo_sum - fee)
|
||||
if budget < 0:
|
||||
raise ValueError(f"Not enough funds for channel opening.")
|
||||
change = utxo_sum - budget - fee
|
||||
return utxos, budget, change, fee
|
||||
|
||||
else: # we need to do coin selection
|
||||
selected_utxos = []
|
||||
utxo_sum = 0
|
||||
for utxo in available_utxos:
|
||||
utxo_sum += utxo.amount_sat
|
||||
selected_utxos.append(utxo)
|
||||
num_p2wkh, num_np2wkh = count_input_types(selected_utxos)
|
||||
fee = calculate_fees(
|
||||
sat_per_vbyte,
|
||||
num_p2wkh_inputs=num_p2wkh,
|
||||
num_np2wkh_inputs=num_np2wkh,
|
||||
num_channels=num_channels,
|
||||
has_change=True
|
||||
)
|
||||
if total_amount_requested + fee + anchor_reserve <= utxo_sum:
|
||||
change = utxo_sum - total_amount_requested - fee
|
||||
return selected_utxos, total_amount_requested, change, fee
|
||||
raise ValueError("Don't have enough funds.")
|
||||
|
||||
|
||||
class ChannelOpener(object):
|
||||
"""Opens multiple channels at once."""
|
||||
|
|
@ -196,39 +35,24 @@ class ChannelOpener(object):
|
|||
def _parse_amounts(amounts: str) -> Optional[List[int]]:
|
||||
if amounts:
|
||||
amount_ints = amounts.split(',')
|
||||
amount_ints = [int(a) for a in amount_ints]
|
||||
return amount_ints
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_utxo_outpoints(utxos_str: str) -> Optional[List[UTXO]]:
|
||||
if utxos_str:
|
||||
utxo_strings = utxos_str.split(',')
|
||||
utxos = []
|
||||
for u in utxo_strings:
|
||||
try:
|
||||
txid, output_index = u.split(':')
|
||||
except ValueError:
|
||||
raise ValueError("utxo format is not of txid:index")
|
||||
utxos.append(
|
||||
UTXO(txid=str(txid), output_index=int(output_index))
|
||||
)
|
||||
return utxos
|
||||
else:
|
||||
return None
|
||||
amounts_split = []
|
||||
for a in amount_ints:
|
||||
a = int(a)
|
||||
if a < 0:
|
||||
raise ValueError("amount negative")
|
||||
amounts_split.append(a)
|
||||
return amounts_split
|
||||
return None
|
||||
|
||||
def open_channels(self, *, pubkeys: str, amounts: str = None,
|
||||
utxos: Optional[str] = None, sat_per_vbyte=1,
|
||||
total_amount: Optional[int] = None, reckless=False,
|
||||
private=False):
|
||||
sat_per_vbyte=1, total_amount: Optional[int] = None,
|
||||
private=False, test=False):
|
||||
"""
|
||||
Performs input checks on parameters and performs user interaction for
|
||||
batch channel opening.
|
||||
|
||||
pubkeys: comma separated nodeid1,nodeid2,...
|
||||
amounts: comma separated amount1,amount2,...
|
||||
utxos: txid:output_idx,txid:output_idx,...
|
||||
sat_per_vbyte: onchain fee rate
|
||||
total_amount: if amounts are not specified, a total amount is
|
||||
distributed to all peers
|
||||
|
|
@ -236,9 +60,8 @@ class ChannelOpener(object):
|
|||
Steps:
|
||||
1. connect to nodes
|
||||
2. report about errors and demand actions (TODO)
|
||||
3. check provided utxos (if any)
|
||||
4. calculate budget without fees and anchor reserve
|
||||
5. open channels
|
||||
3. calculate amounts
|
||||
4. open channels
|
||||
|
||||
"""
|
||||
# Possible improvements:
|
||||
|
|
@ -246,12 +69,16 @@ class ChannelOpener(object):
|
|||
pubkeys = self._parse_pubkeys(pubkeys)
|
||||
amounts = self._parse_amounts(amounts)
|
||||
|
||||
if not amounts and not total_amount:
|
||||
raise ValueError("Please specify either the total amount or amounts.")
|
||||
|
||||
if amounts and total_amount:
|
||||
raise ValueError("Specify either amounts or total amount.")
|
||||
|
||||
if amounts and len(amounts) != len(pubkeys):
|
||||
raise ValueError("Number of amounts is not equal to number of"
|
||||
"node pubkeys.")
|
||||
|
||||
# 1. connect to nodes
|
||||
try:
|
||||
pubkeys_succeeded = self.node._connect_nodes(pubkeys)
|
||||
|
|
@ -270,94 +97,26 @@ class ChannelOpener(object):
|
|||
# user can copy-paste
|
||||
|
||||
# 3. check utxos
|
||||
wallet_utxos = self.node.get_utxos()
|
||||
wallet_balance = sum(utxo.amount_sat for utxo in wallet_utxos)
|
||||
user_provided_utxos = self._parse_utxo_outpoints(utxos)
|
||||
|
||||
available_utxos = []
|
||||
if user_provided_utxos:
|
||||
for utxo in user_provided_utxos:
|
||||
if utxo in wallet_utxos:
|
||||
# need to do index lookups here to also get additional info on the utxos
|
||||
available_utxos.append(wallet_utxos[wallet_utxos.index(utxo)])
|
||||
else:
|
||||
raise ValueError(f"UTXO {utxo} is not controlled by the wallet")
|
||||
else:
|
||||
available_utxos = wallet_utxos
|
||||
available_balance = sum(utxo.amount_sat for utxo in available_utxos)
|
||||
available_utxos = self.node.get_utxos()
|
||||
wallet_balance = sum(utxo.amount_sat for utxo in available_utxos)
|
||||
|
||||
if not available_utxos:
|
||||
raise ValueError("no UTXOs available'")
|
||||
logger.info(">>> Available UTXOs:")
|
||||
for utxo in available_utxos:
|
||||
logger.debug(f" {utxo.txid}:{utxo.output_index} {utxo.amount_sat} sat")
|
||||
|
||||
if available_balance > wallet_balance - ANCHOR_RESERVE:
|
||||
# the user included maybe too many UTXOs that may spend beyond the anchor reserve
|
||||
logger.debug(" Need to potentially consider anchor outputs.")
|
||||
anchor_reserve = ANCHOR_RESERVE
|
||||
else:
|
||||
anchor_reserve = 0
|
||||
|
||||
if (sum(utxo.amount_sat for utxo in available_utxos) - anchor_reserve) \
|
||||
/ num_channels < MIN_CHANNEL_SIZE:
|
||||
raise ValueError(f"The total available funds are not enough to "
|
||||
f"fund {num_channels} channels with minimal size of "
|
||||
f"{MIN_CHANNEL_SIZE} sat. (Anchor reserve: "
|
||||
f"{anchor_reserve} sat.)")
|
||||
|
||||
# 4. calculate budget
|
||||
# Amounts and total_amount are mutually exclusive. Total amount of None
|
||||
# indicates a full spend.
|
||||
# Amounts and total_amount are mutually exclusive.
|
||||
if amounts:
|
||||
total_amount = sum(amounts)
|
||||
if total_amount < 100:
|
||||
total_amount = None
|
||||
elif total_amount:
|
||||
amounts = [int(total_amount / num_channels) for _ in pubkeys]
|
||||
else:
|
||||
total_amount = None
|
||||
|
||||
# If the total amount doesn't resepect the anchor reserve, make it a full spend.
|
||||
if total_amount and total_amount > available_balance - anchor_reserve:
|
||||
total_amount = None
|
||||
|
||||
utxos, budget, change, fee = provide_coins(
|
||||
utxos=available_utxos,
|
||||
spend_all_utxos=bool(utxos), # user provided some utxos
|
||||
total_amount_requested=total_amount,
|
||||
sat_per_vbyte=sat_per_vbyte,
|
||||
num_channels=num_channels,
|
||||
anchor_reserve=anchor_reserve,
|
||||
)
|
||||
logger.info(">>> Used UTXOs:")
|
||||
for utxo in utxos:
|
||||
logger.info(f" {utxo.txid}:{utxo.output_index} {utxo.amount_sat} sat")
|
||||
logger.info(f">>> Channels will be {'private' if private else 'public'}.")
|
||||
|
||||
def rescale(amounts: List[int], rescaled_total_amount: int) -> List[int]:
|
||||
"""Rescales list of amounts to amounts with sum of
|
||||
rescaled_total_amount, fixing also rounding errors."""
|
||||
tot_amt = sum(amounts)
|
||||
amounts = [rescaled_total_amount * a // tot_amt for a in amounts]
|
||||
# handle rounding errors
|
||||
diff = rescaled_total_amount - sum(amounts)
|
||||
amounts[-1] += diff
|
||||
return amounts
|
||||
|
||||
if not total_amount:
|
||||
if not amounts:
|
||||
# distribute the total budget equally over all channels
|
||||
amounts = [int(budget / num_channels) for _ in pubkeys]
|
||||
else:
|
||||
amounts = rescale(amounts, budget)
|
||||
if total_amount > wallet_balance - ANCHOR_RESERVE:
|
||||
raise ValueError("Total amount exceeds wallet balance (anchor reserve included)")
|
||||
|
||||
logger.info(f" Channel capacities: {amounts} sat ({sum(amounts)} sat total).")
|
||||
logger.info(f" Fees: {fee} sat ({sat_per_vbyte} sat/vbyte).")
|
||||
logger.info(f" Change: {change} sat.")
|
||||
|
||||
# TODO: check connected node's features and allow wumbo channels
|
||||
# if own node supports it
|
||||
# if own node supports it
|
||||
for amount in amounts:
|
||||
if amount > WUMBO_LIMIT:
|
||||
raise ValueError("Wumbo channels (capacity bigger than 16777215 sat) not yet supported.")
|
||||
|
|
@ -367,11 +126,9 @@ class ChannelOpener(object):
|
|||
self.node.open_channels(
|
||||
pubkeys=pubkeys,
|
||||
amounts_sat=amounts,
|
||||
change_sat=change,
|
||||
utxos=utxos,
|
||||
reckless=reckless,
|
||||
private=private,
|
||||
sat_per_vbyte=sat_per_vbyte,
|
||||
test=test,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
"""PSBT (BIP 174) utilities.
|
||||
https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki
|
||||
Sources:
|
||||
https://github.com/Jason-Les/python-psbt
|
||||
Programming Bitcoin book, Jimmy Song
|
||||
"""
|
||||
from io import BytesIO
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
PSBT_MAGIC_SEPARATOR = b'psbt' + b'\xFF'
|
||||
PSBT_GLOBAL_UNSIGNED_TX = b'\x00'
|
||||
|
||||
|
||||
def little_endian_to_int(b: bytes):
|
||||
'''little_endian_to_int takes bytes sequence as a little-endian number.
|
||||
Returns an integer'''
|
||||
# use the from_bytes method of int
|
||||
return int.from_bytes(b, 'little')
|
||||
|
||||
|
||||
def read_varint(s: BytesIO):
|
||||
'''read_varint reads a variable integer from a stream'''
|
||||
i = s.read(1)[0]
|
||||
if i == 0xfd:
|
||||
# 0xfd means the next two bytes are the number
|
||||
return little_endian_to_int(s.read(2))
|
||||
elif i == 0xfe:
|
||||
# 0xfe means the next four bytes are the number
|
||||
return little_endian_to_int(s.read(4))
|
||||
elif i == 0xff:
|
||||
# 0xff means the next eight bytes are the number
|
||||
return little_endian_to_int(s.read(8))
|
||||
else:
|
||||
# anything else is just the integer
|
||||
return i
|
||||
|
||||
|
||||
def parse_key_value(stream: BytesIO) -> Tuple[Optional[bytes], Optional[bytes]]:
|
||||
key_length = read_varint(stream)
|
||||
# a key length of 0 represents a separator
|
||||
if key_length == 0:
|
||||
return None, None
|
||||
key = stream.read(key_length)
|
||||
val_length = read_varint(stream)
|
||||
val = stream.read(val_length)
|
||||
return key, val
|
||||
|
||||
|
||||
def extract_psbt_inputs_outputs(psbt: bytes) -> Tuple[int, int, List[int]]:
|
||||
"""Parses only the transaction in the global map of a PSBT representing
|
||||
an unsigned transaction and returns the number of inputs, outputs, and the
|
||||
individual amounts."""
|
||||
stream = BytesIO(psbt)
|
||||
# parse header
|
||||
header = stream.read(5)
|
||||
if header != PSBT_MAGIC_SEPARATOR:
|
||||
raise ValueError("wrong psbt header")
|
||||
|
||||
# parse global
|
||||
key, value = parse_key_value(stream)
|
||||
if key != PSBT_GLOBAL_UNSIGNED_TX:
|
||||
raise NotImplementedError("Can't parse PSBTs that contain other data than unsigned transactions.")
|
||||
|
||||
# parse transaction
|
||||
transaction_stream = BytesIO(value)
|
||||
version = little_endian_to_int(transaction_stream.read(4))
|
||||
|
||||
# parse inputs
|
||||
num_inputs = read_varint(transaction_stream)
|
||||
for _ in range(num_inputs):
|
||||
prev_tx = transaction_stream.read(32)[::-1]
|
||||
prev_index = little_endian_to_int(transaction_stream.read(4))
|
||||
script_sig_length = read_varint(transaction_stream)
|
||||
script_sig = transaction_stream.read(script_sig_length)
|
||||
sequence = little_endian_to_int(transaction_stream.read(4))
|
||||
|
||||
# parse outputs
|
||||
num_outputs = read_varint(transaction_stream)
|
||||
output_amounts = []
|
||||
for _ in range(num_outputs):
|
||||
amount = little_endian_to_int(transaction_stream.read(8))
|
||||
script_pubkey_length = read_varint(transaction_stream)
|
||||
script_pubkey = transaction_stream.read(script_pubkey_length)
|
||||
output_amounts.append(amount)
|
||||
return num_inputs, num_outputs, output_amounts
|
||||
|
|
@ -328,39 +328,23 @@ class Parser(object):
|
|||
# cmd: openchannels
|
||||
self.parser_openchannels = subparsers.add_parser(
|
||||
'openchannels',
|
||||
help='opens multiple channels with UTXO control',
|
||||
help='opens multiple channels',
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
self.parser_openchannels.add_argument(
|
||||
'--utxos',
|
||||
type=str,
|
||||
help='Comma-separated list of utxos of format '
|
||||
'txid:output_index,txid:output_index which belong to the LND '
|
||||
'wallet (see lncli listunspent).')
|
||||
self.parser_openchannels.add_argument(
|
||||
'--amounts',
|
||||
type=str,
|
||||
help='Comma-separated list of channel amounts in sat 1000000,2000000,... '
|
||||
'A list of amounts with sum < 100 denotes relative amounts that are rescaled '
|
||||
'to the sum of all UTXOs provided, such that no change is created. '
|
||||
'If the amounts provided are larger than the funds available, '
|
||||
'they will automatically be reduced proportionally such that no change is created. '
|
||||
'Change is created if anchor reserve funds would be depleted.')
|
||||
help='Comma-separated list of channel amounts in sat 1000000,2000000,... ',
|
||||
)
|
||||
self.parser_openchannels.add_argument(
|
||||
'--total-amount',
|
||||
type=int,
|
||||
help='Total amount in sat to open channels.'
|
||||
'Amounts and total amount flags are mutually exclusive. '
|
||||
'If none of the two amounts are given, all available UTXOs '
|
||||
'will be spent.')
|
||||
'Amounts and total amount flags are mutually exclusive.')
|
||||
self.parser_openchannels.add_argument(
|
||||
'--sat-per-vbyte',
|
||||
type=int,
|
||||
default=1,
|
||||
help='The fee rate in sat per vbyte that will be targeted.')
|
||||
self.parser_openchannels.add_argument(
|
||||
'--reckless',
|
||||
help='If this flag is provided, the user will not be asked '
|
||||
'to agree to channel opening.', action='store_true')
|
||||
self.parser_openchannels.add_argument(
|
||||
'--private', help='The channels will not be announced to the network.',
|
||||
action='store_true')
|
||||
|
|
@ -576,10 +560,8 @@ class Parser(object):
|
|||
channel_opener.open_channels(
|
||||
pubkeys=args.pubkeys,
|
||||
amounts=args.amounts,
|
||||
utxos=args.utxos,
|
||||
sat_per_vbyte=args.sat_per_vbyte,
|
||||
total_amount=args.total_amount,
|
||||
reckless=args.reckless,
|
||||
private=args.private,
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ class Batchopen(TestNetwork):
|
|||
channel_opener.open_channels(
|
||||
pubkeys=pubkey_input,
|
||||
amounts=f"{amount1},{amount2}",
|
||||
reckless=True,
|
||||
sat_per_vbyte=20,
|
||||
test=True,
|
||||
)
|
||||
confirm_transactions(self.testnet)
|
||||
wallet_utxos_after = self.lndnode.get_utxos()
|
||||
|
|
@ -67,8 +67,8 @@ class Batchopen(TestNetwork):
|
|||
channel_opener.open_channels(
|
||||
pubkeys=pubkey_input,
|
||||
total_amount=total_amount,
|
||||
reckless=True,
|
||||
private=True,
|
||||
test=True,
|
||||
)
|
||||
confirm_transactions(self.testnet)
|
||||
wallet_utxos_after = self.lndnode.get_utxos()
|
||||
|
|
@ -83,122 +83,18 @@ class Batchopen(TestNetwork):
|
|||
num_private_channels = len([True for v in channels_after.values() if v['private']])
|
||||
self.assertEqual(2, num_private_channels)
|
||||
|
||||
with self.subTest(msg="(explicit), spend fully, no change created"):
|
||||
address = self.testnet.master_node.getaddress()
|
||||
self.testnet.bitcoind.sendtoaddress(address, 0.10_000_000)
|
||||
confirm_transactions(self.testnet)
|
||||
|
||||
wallet_utxos_before = self.lndnode.get_utxos()
|
||||
channels_before = self.lndnode.get_open_channels()
|
||||
# maybe make sure we select the correct utxo
|
||||
spent_utxo = wallet_utxos_before[0]
|
||||
utxo_input = f"{spent_utxo.txid}:{spent_utxo.output_index}"
|
||||
channel_opener.open_channels(
|
||||
utxos=utxo_input,
|
||||
pubkeys=pubkey_input,
|
||||
reckless=True,
|
||||
)
|
||||
confirm_transactions(self.testnet)
|
||||
wallet_utxos_after = self.lndnode.get_utxos()
|
||||
channels_after = self.lndnode.get_open_channels()
|
||||
|
||||
self.assertEqual(2, len(channels_after) - len(channels_before))
|
||||
self.assertEqual(1, len(wallet_utxos_before) - len(wallet_utxos_after))
|
||||
|
||||
self.assertNotIn(spent_utxo, wallet_utxos_after)
|
||||
|
||||
# clear wallet, but keep anchor reserves, leaves 50000 sat
|
||||
self.testnet.master_node.rpc(["sendcoins", "--sweepall", "bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw"])
|
||||
confirm_transactions(self.testnet)
|
||||
|
||||
with self.subTest(msg="implicit coins, relative amounts, anchor reserve created"):
|
||||
with self.subTest(msg="too large amounts"):
|
||||
address = self.testnet.master_node.getaddress()
|
||||
self.testnet.bitcoind.sendtoaddress(address, 0.10_000_000)
|
||||
confirm_transactions(self.testnet)
|
||||
|
||||
wallet_utxos_before = self.lndnode.get_utxos()
|
||||
channels_before = self.lndnode.get_open_channels()
|
||||
channel_opener.open_channels(
|
||||
amounts="1,2",
|
||||
self.assertRaises(ValueError, lambda: channel_opener.open_channels(
|
||||
amounts="10_000_000,10_000_000",
|
||||
pubkeys=pubkey_input,
|
||||
reckless=True,
|
||||
)
|
||||
confirm_transactions(self.testnet)
|
||||
wallet_utxos_after = self.lndnode.get_utxos()
|
||||
channels_after = self.lndnode.get_open_channels()
|
||||
|
||||
self.assertEqual(2, len(channels_after) - len(channels_before))
|
||||
self.assertEqual(1, len(wallet_utxos_before) - len(wallet_utxos_after))
|
||||
|
||||
wallet_utxo_amounts = [utxo.amount_sat for utxo in wallet_utxos_after]
|
||||
self.assertIn(openchannels.ANCHOR_RESERVE, wallet_utxo_amounts)
|
||||
|
||||
with self.subTest(msg="implicit coins, nested-P2WKH, too large amounts"):
|
||||
amount1 = 5_000_000
|
||||
amount2 = 6_000_000
|
||||
address = self.testnet.master_node.getaddress(address_type='np2wkh')
|
||||
self.testnet.bitcoind.sendtoaddress(address, 0.10_000_000)
|
||||
confirm_transactions(self.testnet)
|
||||
|
||||
wallet_utxos_before = self.lndnode.get_utxos()
|
||||
channels_before = self.lndnode.get_open_channels()
|
||||
channel_opener.open_channels(
|
||||
amounts=f"{amount1},{amount2}",
|
||||
pubkeys=pubkey_input,
|
||||
reckless=True,
|
||||
)
|
||||
confirm_transactions(self.testnet)
|
||||
wallet_utxos_after = self.lndnode.get_utxos()
|
||||
channels_after = self.lndnode.get_open_channels()
|
||||
|
||||
self.assertEqual(2, len(channels_after) - len(channels_before))
|
||||
self.assertEqual(1, len(wallet_utxos_before) - len(wallet_utxos_after))
|
||||
|
||||
total_capacity_before = sum([channel['capacity'] for channel in channels_before.values()])
|
||||
total_capacity_after = sum([channel['capacity'] for channel in channels_after.values()])
|
||||
# test that we have reduced the amounts
|
||||
self.assertGreater(amount1 + amount2, total_capacity_after - total_capacity_before)
|
||||
|
||||
with self.subTest(msg="implicit coins, nested-P2WKH, too large amounts"):
|
||||
address = self.testnet.master_node.getaddress(address_type='np2wkh')
|
||||
self.testnet.bitcoind.sendtoaddress(address, 0.10_000_000)
|
||||
confirm_transactions(self.testnet)
|
||||
total_amount = 20_000_000
|
||||
wallet_utxos_before = self.lndnode.get_utxos()
|
||||
channels_before = self.lndnode.get_open_channels()
|
||||
channel_opener.open_channels(
|
||||
total_amount=total_amount,
|
||||
pubkeys=pubkey_input,
|
||||
reckless=True,
|
||||
)
|
||||
confirm_transactions(self.testnet)
|
||||
wallet_utxos_after = self.lndnode.get_utxos()
|
||||
channels_after = self.lndnode.get_open_channels()
|
||||
|
||||
self.assertEqual(2, len(channels_after) - len(channels_before))
|
||||
self.assertEqual(1, len(wallet_utxos_before) - len(wallet_utxos_after))
|
||||
|
||||
total_capacity_before = sum([channel['capacity'] for channel in channels_before.values()])
|
||||
total_capacity_after = sum([channel['capacity'] for channel in channels_after.values()])
|
||||
# test that we have reduced the total amount
|
||||
self.assertGreater(total_amount, total_capacity_after - total_capacity_before)
|
||||
|
||||
with self.subTest(msg="implicit coins, full spend, wumbo violation"):
|
||||
address = self.testnet.master_node.getaddress()
|
||||
self.testnet.bitcoind.sendtoaddress(address, (2 * openchannels.WUMBO_LIMIT + 1000) * 1E-8)
|
||||
|
||||
confirm_transactions(self.testnet)
|
||||
self.assertRaises(
|
||||
ValueError, channel_opener.open_channels,
|
||||
pubkeys=pubkey_input,
|
||||
reckless=True,
|
||||
)
|
||||
test=True,
|
||||
))
|
||||
asyncio.run(run_tests())
|
||||
|
||||
|
||||
class FeeTest(TestCase):
|
||||
def test_fee_estimation(self):
|
||||
self.assertNotEqual(165, openchannels.calculate_fees(sat_per_vbyte=1, num_p2wkh_inputs=1, num_np2wkh_inputs=0, num_channels=2, has_change=False))
|
||||
self.assertNotEqual(196, openchannels.calculate_fees(sat_per_vbyte=1, num_p2wkh_inputs=1, num_np2wkh_inputs=0, num_channels=2, has_change=True))
|
||||
self.assertEqual(227, openchannels.calculate_fees(sat_per_vbyte=1, num_p2wkh_inputs=1, num_np2wkh_inputs=0, num_channels=2, has_change=True)) # reproduce bug in lnd
|
||||
self.assertEqual(196, openchannels.calculate_fees(sat_per_vbyte=1, num_p2wkh_inputs=1, num_np2wkh_inputs=0, num_channels=2, has_change=False)) # reproduce bug in lnd
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
from binascii import a2b_base64
|
||||
from unittest import TestCase
|
||||
|
||||
from lndmanage.lib import psbt
|
||||
|
||||
|
||||
class PSBTTest(TestCase):
|
||||
def test_psbt_magic(self):
|
||||
self.assertEqual(bytes.fromhex('70736274FF'), psbt.PSBT_MAGIC_SEPARATOR)
|
||||
|
||||
def test_psbt_from_bytes(self):
|
||||
data = a2b_base64("cHNidP8BAIkCAAAAAW18fk+d7Xn+uwhZbaB+QqCAL8hJH58FShnRPbJnEuBtAAAAAAD/////Asef/AEAAAAAIgAgd/G0Fd8Bj6JPRZe3l0jTyNymOS+MzCuF6R6afTUJlb+QP/kDAAAAACIAIHm/fFMVYD11fLjoRMGLYFCkqP8XnFKusmHfstlELelHAAAAAAABAN4CAAAAAAEBPVfLJjNQObnakrPrX7dFrViGGPhbdTLQdAZg8FLeMvYBAAAAAP7///8CAOH1BQAAAAAWABS6BvZzsfWYFhsFXzaUDQhHnBfRddmXgR0BAAAAFgAUV4AyH5gnj5MPgyOVERAkgpMp0mUCRzBEAiAOXvriTqHmFxWv6sBKPhNnsToTr24xUssEEKvI2orduQIgUjbrfAFSIDWWhe8WXwTbMeWB9IoHevu1snICGsAMoucBIQJ21ss2GSM0fV8F3ILVqzW3SNdunSNjSNh1CGQuqwq7q78AAAABAR8A4fUFAAAAABYAFLoG9nOx9ZgWGwVfNpQNCEecF9F1AQMEAQAAACIGA0DnWsAOWoFkVk9RVhWWV95PWI8PEuaT5EcT403nRwFMGAAAAABUAACAAAAAgAAAAIAAAAAABQAAAAAAAA==")
|
||||
num_inputs, num_outputs, amounts = psbt.extract_psbt_inputs_outputs(data)
|
||||
self.assertEqual((1, 2, [33333191, 66666384]), (num_inputs, num_outputs, amounts))
|
||||
Loading…
Add table
Add a link
Reference in a new issue