mirror of
https://github.com/JoinMarket-Org/joinmarket-clientserver.git
synced 2026-08-15 12:50:57 +02:00
refactoring to improve readability and testing added support for taking the fee from multiple owned outputs as needed added unit tests removed same mixdepth restriction for inputs added option for specifying which output to deduct fees from added additional test cases refactoring: moved tx_vsize to jmbitcoin package added a sanity check to ensure that the transaction id supplied belongs to our wallet fixed a linting error
283 lines
11 KiB
Python
Executable file
283 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
from decimal import Decimal
|
|
from jmbase import get_log, hextobin, bintohex
|
|
from jmbase.support import EXIT_SUCCESS, EXIT_FAILURE, EXIT_ARGERROR, jmprint
|
|
from jmclient import jm_single, load_program_config, open_test_wallet_maybe, get_wallet_path, WalletService
|
|
from jmclient.cli_options import OptionParser, add_base_options
|
|
import jmbitcoin as btc
|
|
import sys
|
|
|
|
jlog = get_log()
|
|
parser = OptionParser(
|
|
usage='usage: %prog [options] [wallet file] txid',
|
|
description=
|
|
'Bumps the fee on a wallet transaction')
|
|
parser.add_option('--psbt',
|
|
action='store_true',
|
|
dest='with_psbt',
|
|
default=False,
|
|
help='output as psbt instead of '
|
|
'broadcasting the transaction.')
|
|
parser.add_option('-o',
|
|
'--output',
|
|
action='store',
|
|
type='int',
|
|
dest='output',
|
|
default=-1,
|
|
help='optionally specify which output to deduct the fee from. Outputs '
|
|
'are 0-indexed meaning the first output has an index value of 0 and the '
|
|
'second, an index value of 1 and so on.')
|
|
parser.add_option('-f',
|
|
'--txfee',
|
|
action='store',
|
|
type='int',
|
|
dest='txfee',
|
|
default=-1,
|
|
help='Bitcoin miner tx_fee to use for transaction(s). A number higher '
|
|
'than 1000 is used as "satoshi per KB" tx fee. A number lower than that '
|
|
'uses the dynamic fee estimation of your blockchain provider as '
|
|
'confirmation target. This temporarily overrides the "tx_fees" setting '
|
|
'in your joinmarket.cfg. Works the same way as described in it. Check '
|
|
'it for examples.')
|
|
parser.add_option('-a',
|
|
'--amtmixdepths',
|
|
action='store',
|
|
type='int',
|
|
dest='amtmixdepths',
|
|
help='number of mixdepths in wallet, default 5',
|
|
default=5)
|
|
parser.add_option('-g',
|
|
'--gap-limit',
|
|
type="int",
|
|
action='store',
|
|
dest='gaplimit',
|
|
help='gap limit for wallet, default=6',
|
|
default=6)
|
|
parser.add_option('--yes',
|
|
action='store_true',
|
|
dest='answeryes',
|
|
default=False,
|
|
help='answer yes to everything')
|
|
add_base_options(parser)
|
|
|
|
def check_valid_candidate(orig_tx, wallet, output_index=-1):
|
|
orig_tx_info = jm_single().bc_interface.get_transaction(orig_tx.GetTxid()[::-1])
|
|
# check that the transaction is still unconfirmed
|
|
if orig_tx_info['confirmations'] > 0:
|
|
raise RuntimeWarning('Transaction already confirmed. Nothing to do.')
|
|
|
|
# all transaction inputs must belong to the wallet
|
|
own_inputs_n = len(wallet.inputs_consumed_by_tx(orig_tx))
|
|
tx_inputs_n = len(orig_tx.vin)
|
|
|
|
if own_inputs_n != tx_inputs_n:
|
|
raise ValueError('Transaction inputs should belong to the wallet.')
|
|
|
|
# at least one input should signal opt-in rbf
|
|
if not any([vin.nSequence <= 0xffffffff - 2 for vin in orig_tx.vin]):
|
|
raise ValueError('Transaction not replaceable.')
|
|
|
|
# 1. If output_index is specified, check that the output exist
|
|
# 2. If not, check that we have only one output
|
|
# 3. If not, wallet should own at least one output that we can deduct
|
|
# fees from.
|
|
if output_index >= 0 and len(orig_tx.vout) > output_index:
|
|
return None
|
|
elif len(orig_tx.vout) == 1:
|
|
return None
|
|
elif not any(
|
|
[wallet.is_known_script(vout.scriptPubKey) for vout in orig_tx.vout]
|
|
):
|
|
raise ValueError('Transaction has no obvious output we can deduct fees '
|
|
'from. Specify the output to pay from using the -o '
|
|
'option.')
|
|
|
|
def compute_bump_fee(tx, fee_per_kb):
|
|
tx_info = jm_single().bc_interface.get_transaction(tx.GetTxid()[::-1])
|
|
tx_size_n = btc.tx_vsize(tx)
|
|
tx_fee = btc.amount_to_sat(abs(tx_info['fee']))
|
|
proposed_fee_rate = fee_per_kb / Decimal(1000.0)
|
|
proposed_fee = int(tx_size_n * proposed_fee_rate)
|
|
min_proposed_fee = tx_fee + tx_size_n
|
|
min_proposed_fee_rate = min_proposed_fee / Decimal(tx_size_n)
|
|
|
|
if proposed_fee < (tx_fee + tx_size_n):
|
|
raise ValueError('Proposed fee for transaction replacement: '
|
|
'%d sats (%.1f sat/vB) is below minimum required '
|
|
'for relay: %d sats (%.1f sat/vB). '
|
|
'Try using a higher fee setting.'
|
|
% (proposed_fee, proposed_fee_rate,
|
|
min_proposed_fee, min_proposed_fee_rate))
|
|
|
|
return (proposed_fee - tx_fee)
|
|
|
|
def prepare_transaction(new_tx, old_tx, wallet):
|
|
input_scripts = {}
|
|
spent_outs = []
|
|
for ix, vin in enumerate(new_tx.vin):
|
|
script = wallet.pubkey_to_script(
|
|
btc.extract_pubkey_from_witness(old_tx, ix)[0])
|
|
tx_info = jm_single().bc_interface.get_transaction(
|
|
hextobin(btc.b2lx(new_tx.vin[ix].prevout.hash)))
|
|
prev_tx = btc.CTransaction.deserialize(hextobin(tx_info['hex']))
|
|
amount = prev_tx.vout[new_tx.vin[ix].prevout.n].nValue
|
|
|
|
input_scripts[ix] = (script, amount)
|
|
spent_outs.append(btc.CMutableTxOut(amount, script))
|
|
|
|
return (input_scripts, spent_outs)
|
|
|
|
def sign_transaction(new_tx, old_tx, wallet_service):
|
|
input_scripts, _ = prepare_transaction(new_tx, old_tx, wallet_service.wallet)
|
|
success, msg = wallet_service.sign_tx(new_tx, input_scripts)
|
|
if not success:
|
|
raise RuntimeError("Failed to sign transaction, quitting. Error msg: " + msg)
|
|
|
|
def sign_psbt(new_tx, old_tx, wallet_service):
|
|
_, spent_outs = prepare_transaction(new_tx, old_tx, wallet_service.wallet)
|
|
unsigned_psbt = wallet_service.create_psbt_from_tx(
|
|
new_tx, spent_outs=spent_outs)
|
|
signed_psbt, err = wallet_service.sign_psbt(unsigned_psbt.serialize())
|
|
|
|
if err:
|
|
raise RuntimeError("Failed to sign PSBT, quitting. Error message: " + err)
|
|
|
|
return btc.PartiallySignedTransaction.deserialize(signed_psbt)
|
|
|
|
def create_bumped_tx(tx, fee_per_kb, wallet, output_index=-1):
|
|
check_valid_candidate(tx, wallet, output_index)
|
|
fee = compute_bump_fee(tx, fee_per_kb)
|
|
|
|
if (
|
|
len(tx.vout) == 1
|
|
and tx.vout[0].nValue >= (fee + jm_single().BITCOIN_DUST_THRESHOLD)
|
|
):
|
|
tx.vout[0].nValue -= fee
|
|
fee = 0
|
|
elif (
|
|
output_index >= 0
|
|
and len(tx.vout) > output_index
|
|
and tx.vout[output_index].nValue >= (fee + jm_single().BITCOIN_DUST_THRESHOLD)
|
|
):
|
|
tx.vout[output_index].nValue -= fee
|
|
fee = 0
|
|
else:
|
|
for ix, vout in enumerate(tx.vout):
|
|
if wallet.is_known_script(vout.scriptPubKey) and fee > 0:
|
|
# check if the output is a change address
|
|
if wallet.script_to_path(vout.scriptPubKey)[-2] != 1:
|
|
continue
|
|
|
|
# deduct fee from the change
|
|
tx.vout[ix].nValue -= fee
|
|
fee = 0
|
|
|
|
# if the output value is less than zero, remove it
|
|
if tx.vout[ix].nValue < 0:
|
|
jlog.info("Dynamically calculated change lower than zero; dropping.")
|
|
|
|
# update fee to the additional amount needed to pay for the tx
|
|
# accounting for the removal of the output
|
|
fee = abs(tx.vout[ix].nValue) - len(tx.vout[ix].serialize())
|
|
tx.vout.remove(vout)
|
|
continue
|
|
|
|
# if the output value is below the dust threshold, remove it
|
|
if tx.vout[ix].nValue <= jm_single().BITCOIN_DUST_THRESHOLD:
|
|
jlog.info("Dynamically calculated change lower than dust: " +
|
|
btc.amount_to_str(tx.vout[ix].nValue) + "; dropping.")
|
|
tx.vout.remove(vout)
|
|
break
|
|
|
|
# create new transaction from the old
|
|
# there's the possibility that it returns the same transaction as the old
|
|
# if no outputs were available to deduct a fee from
|
|
return btc.CMutableTransaction(
|
|
tx.vin, tx.vout, nLockTime=tx.nLockTime,
|
|
nVersion=tx.nVersion)
|
|
|
|
if __name__ == '__main__':
|
|
(options, args) = parser.parse_args()
|
|
load_program_config(config_path=options.datadir)
|
|
if len(args) < 2:
|
|
parser.error("JoinMarket bumpfee needs arguments:"
|
|
" wallet file and txid.")
|
|
sys.exit(EXIT_ARGERROR)
|
|
|
|
wallet_name = args[0]
|
|
txid = args[1]
|
|
|
|
# If tx_fees are set manually by CLI argument, override joinmarket.cfg:
|
|
if int(options.txfee) > 0:
|
|
jm_single().config.set("POLICY", "tx_fees", str(options.txfee))
|
|
fee_per_kb = jm_single().bc_interface.estimate_fee_per_kb(
|
|
jm_single().config.getint("POLICY", "tx_fees"))
|
|
if fee_per_kb is None:
|
|
raise RuntimeError("Cannot estimate fee per kB, possibly" +
|
|
" a failure of connection to the blockchain.")
|
|
|
|
# open the wallet and synchronize it
|
|
wallet_path = get_wallet_path(wallet_name, None)
|
|
wallet = open_test_wallet_maybe(
|
|
wallet_path, wallet_name, options.amtmixdepths - 1,
|
|
wallet_password_stdin=options.wallet_password_stdin,
|
|
gap_limit=options.gaplimit)
|
|
wallet_service = WalletService(wallet)
|
|
if wallet_service.rpc_error:
|
|
sys.exit(EXIT_FAILURE)
|
|
while not wallet_service.synced:
|
|
wallet_service.sync_wallet(fast=not options.recoversync)
|
|
wallet_service.startService()
|
|
|
|
orig_tx = wallet_service.get_transaction(hextobin(txid))
|
|
|
|
if not orig_tx:
|
|
jlog.error("Could not retrieve the transaction! Maybe it doesn't belong to this wallet?")
|
|
sys.exit(EXIT_FAILURE)
|
|
|
|
try:
|
|
bumped_tx = create_bumped_tx(orig_tx, fee_per_kb, wallet, options.output)
|
|
except ValueError as e:
|
|
jmprint(str(e), 'error')
|
|
sys.exit(EXIT_FAILURE)
|
|
except RuntimeWarning as w:
|
|
jmprint(str(w))
|
|
sys.exit(EXIT_SUCCESS)
|
|
|
|
# sign the transaction
|
|
if options.with_psbt:
|
|
try:
|
|
psbt = sign_psbt(bumped_tx, orig_tx, wallet_service)
|
|
|
|
print("Completed PSBT created: ")
|
|
print(wallet_service.human_readable_psbt(psbt))
|
|
jlog.info("This PSBT is fully signed and can be sent externally for "
|
|
"broadcasting:")
|
|
jlog.info(psbt.to_base64())
|
|
sys.exit(EXIT_SUCCESS)
|
|
except RuntimeError as e:
|
|
jlog.error(str(e))
|
|
sys.exit(EXIT_FAILURE)
|
|
else:
|
|
try:
|
|
sign_transaction(bumped_tx, orig_tx, wallet_service)
|
|
except RuntimeError as e:
|
|
jlog.error(str(e))
|
|
sys.exit(EXIT_FAILURE)
|
|
|
|
jlog.info("Got signed transaction:")
|
|
jlog.info(btc.human_readable_transaction(bumped_tx))
|
|
|
|
if not options.answeryes:
|
|
if input('Would you like to push to the network? (y/n):')[0] != 'y':
|
|
jlog.info("You chose not to broadcast the transaction, quitting.")
|
|
sys.exit(EXIT_SUCCESS)
|
|
|
|
if jm_single().bc_interface.pushtx(bumped_tx.serialize()):
|
|
txid = bintohex(bumped_tx.GetTxid()[::-1])
|
|
jlog.info("Transaction sent: " + txid)
|
|
else:
|
|
jlog.error("Transaction broadcast failed!")
|
|
sys.exit(EXIT_FAILURE)
|
|
|