Merge pull request #137 from bitromortac/2207-batchopen

openchannels: replace with lnd batchopen
This commit is contained in:
bitromortac 2022-07-14 23:02:45 +02:00 committed by GitHub
commit 12edf7c4ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 64 additions and 712 deletions

View file

@ -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.

View file

@ -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]:
"""

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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))