From 69e561474550026a7f8359d1bfa592e598ee2cb7 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 14 Jul 2022 20:04:20 +0200 Subject: [PATCH] openchannels: replace with lnd batchopen --- README.md | 26 +-- lndmanage/lib/node.py | 216 +++---------------------- lndmanage/lib/openchannels.py | 291 +++------------------------------- lndmanage/lib/psbt.py | 85 ---------- lndmanage/lndmanage.py | 26 +-- test/test_openchannels.py | 118 +------------- test/test_psbt.py | 14 -- 7 files changed, 64 insertions(+), 712 deletions(-) delete mode 100644 lndmanage/lib/psbt.py delete mode 100644 test/test_psbt.py diff --git a/README.md b/README.md index 0511d21..afc9789 100644 --- a/README.md +++ b/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. diff --git a/lndmanage/lib/node.py b/lndmanage/lib/node.py index b99c05d..4203ff7 100644 --- a/lndmanage/lib/node.py +++ b/lndmanage/lib/node.py @@ -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]: """ diff --git a/lndmanage/lib/openchannels.py b/lndmanage/lib/openchannels.py index e9aaf38..22c72a9 100644 --- a/lndmanage/lib/openchannels.py +++ b/lndmanage/lib/openchannels.py @@ -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) diff --git a/lndmanage/lib/psbt.py b/lndmanage/lib/psbt.py deleted file mode 100644 index cd32768..0000000 --- a/lndmanage/lib/psbt.py +++ /dev/null @@ -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 diff --git a/lndmanage/lndmanage.py b/lndmanage/lndmanage.py index a5757c8..7076935 100755 --- a/lndmanage/lndmanage.py +++ b/lndmanage/lndmanage.py @@ -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: diff --git a/test/test_openchannels.py b/test/test_openchannels.py index 11ccb5c..0e52b91 100644 --- a/test/test_openchannels.py +++ b/test/test_openchannels.py @@ -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 diff --git a/test/test_psbt.py b/test/test_psbt.py deleted file mode 100644 index 82f047e..0000000 --- a/test/test_psbt.py +++ /dev/null @@ -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))