Browse Source

Merge JoinMarket-Org/joinmarket-clientserver#1676: Multiple (batch) payment support in `direct_send()`

f3f4f0a4fb Multiple (batch) payment support in `direct_send()` (Kristaps Kaupe)

Pull request description:

  Work towards #1012. Changes `direct_send()` to instead of single `amount` and `destination` to accept `dest_and_amounts` which is list of tuples of addresses and amounts instead. Haven't yet implemented and tested actual payments to multiple recipients, but tested that this doesn't break existing stuff.

Top commit has no ACKs.

Tree-SHA512: 02195a28d071c9537cb5297e63854ad2571e0ae9b5e06b850d6173c47d53caae953e9d7671ff861a6584a104d7a59da2293781d4440f7db4814f9b2fc4116c46
master
Kristaps Kaupe 2 years ago
parent
commit
085ef0822a
No known key found for this signature in database
GPG Key ID: 33E472FE870C7E5D
  1. 11
      scripts/joinmarket-qt.py
  2. 3
      scripts/sendpayment.py
  3. 5
      src/jmclient/payjoin.py
  4. 125
      src/jmclient/taker_utils.py
  5. 6
      src/jmclient/wallet_rpc.py
  6. 12
      test/jmclient/test_psbt_wallet.py
  7. 7
      test/jmclient/test_snicker.py
  8. 4
      test/jmclient/test_tx_creation.py

11
scripts/joinmarket-qt.py

@ -801,11 +801,12 @@ class SpendTab(QWidget):
if len(self.changeInput.text().strip()) > 0: if len(self.changeInput.text().strip()) > 0:
custom_change = str(self.changeInput.text().strip()) custom_change = str(self.changeInput.text().strip())
try: try:
txid = direct_send(mainWindow.wallet_service, amount, mixdepth, txid = direct_send(mainWindow.wallet_service, mixdepth,
destaddr, accept_callback=self.checkDirectSend, [(destaddr, amount)],
info_callback=self.infoDirectSend, accept_callback=self.checkDirectSend,
error_callback=self.errorDirectSend, info_callback=self.infoDirectSend,
custom_change_addr=custom_change) error_callback=self.errorDirectSend,
custom_change_addr=custom_change)
except Exception as e: except Exception as e:
JMQtMessageBox(self, e.args[0], title="Error", mbtype="warn") JMQtMessageBox(self, e.args[0], title="Error", mbtype="warn")
return return

3
scripts/sendpayment.py

@ -270,7 +270,8 @@ def main():
sys.exit(EXIT_ARGERROR) sys.exit(EXIT_ARGERROR)
if options.makercount == 0 and not bip78url: if options.makercount == 0 and not bip78url:
tx = direct_send(wallet_service, amount, mixdepth, destaddr, tx = direct_send(wallet_service, mixdepth,
[(destaddr, amount)],
options.answeryes, options.answeryes,
with_final_psbt=options.with_psbt, with_final_psbt=options.with_psbt,
optin_rbf=not options.no_rbf, optin_rbf=not options.no_rbf,

5
src/jmclient/payjoin.py

@ -482,8 +482,9 @@ def make_payment_psbt(manager, accept_callback=None, info_callback=None):
# we can create a standard payment, but have it returned as a PSBT. # we can create a standard payment, but have it returned as a PSBT.
assert isinstance(manager, JMPayjoinManager) assert isinstance(manager, JMPayjoinManager)
assert manager.wallet_service.synced assert manager.wallet_service.synced
payment_psbt = direct_send(manager.wallet_service, manager.amount, payment_psbt = direct_send(manager.wallet_service,
manager.mixdepth, str(manager.destination), manager.mixdepth,
[(str(manager.destination), manager.amount)],
accept_callback=accept_callback, accept_callback=accept_callback,
info_callback=info_callback, info_callback=info_callback,
with_final_psbt=True) with_final_psbt=True)

125
src/jmclient/taker_utils.py

@ -4,7 +4,7 @@ import os
import sys import sys
import time import time
import numbers import numbers
from typing import Callable, Optional, Union from typing import Callable, List, Optional, Tuple, Union
from jmbase import get_log, jmprint, bintohex, hextobin, \ from jmbase import get_log, jmprint, bintohex, hextobin, \
cli_prompt_user_yesno cli_prompt_user_yesno
@ -34,8 +34,10 @@ def get_utxo_scripts(wallet: BaseWallet, utxos: dict) -> list:
script_types.append(wallet.get_outtype(utxo["address"])) script_types.append(wallet.get_outtype(utxo["address"]))
return script_types return script_types
def direct_send(wallet_service: WalletService, amount: int, mixdepth: int, def direct_send(wallet_service: WalletService,
destination: str, answeryes: bool = False, mixdepth: int,
dest_and_amounts: List[Tuple[str, int]],
answeryes: bool = False,
accept_callback: Optional[Callable[[str, str, int, int, Optional[str]], bool]] = None, accept_callback: Optional[Callable[[str, str, int, int, Optional[str]], bool]] = None,
info_callback: Optional[Callable[[str], None]] = None, info_callback: Optional[Callable[[str], None]] = None,
error_callback: Optional[Callable[[str], None]] = None, error_callback: Optional[Callable[[str], None]] = None,
@ -71,86 +73,110 @@ def direct_send(wallet_service: WalletService, amount: int, mixdepth: int,
4. The PSBT object if with_final_psbt is True, and in 4. The PSBT object if with_final_psbt is True, and in
this case the transaction is *NOT* broadcast. this case the transaction is *NOT* broadcast.
""" """
is_sweep = False
outtypes = []
total_outputs_val = 0
#Sanity checks #Sanity checks
assert validate_address(destination)[0] or is_burn_destination(destination) assert isinstance(dest_and_amounts, list)
assert len(dest_and_amounts) > 0
assert custom_change_addr is None or validate_address(custom_change_addr)[0] assert custom_change_addr is None or validate_address(custom_change_addr)[0]
assert amount > 0 or custom_change_addr is None
assert isinstance(mixdepth, numbers.Integral) assert isinstance(mixdepth, numbers.Integral)
assert mixdepth >= 0 assert mixdepth >= 0
assert isinstance(amount, numbers.Integral)
assert amount >=0
assert isinstance(wallet_service.wallet, BaseWallet) assert isinstance(wallet_service.wallet, BaseWallet)
if is_burn_destination(destination): for target in dest_and_amounts:
#Additional checks destination = target[0]
if not isinstance(wallet_service.wallet, FidelityBondMixin): amount = target[1]
log.error("Only fidelity bond wallets can burn coins") assert validate_address(destination)[0] or \
return is_burn_destination(destination)
if answeryes: if amount == 0:
log.error("Burning coins not allowed without asking for confirmation") assert custom_change_addr is None and \
return len(dest_and_amounts) == 1
if mixdepth != FidelityBondMixin.FIDELITY_BOND_MIXDEPTH: is_sweep = True
log.error("Burning coins only allowed from mixdepth " + str( assert isinstance(amount, numbers.Integral)
FidelityBondMixin.FIDELITY_BOND_MIXDEPTH)) assert amount >= 0
return if is_burn_destination(destination):
if amount != 0: #Additional checks
log.error("Only sweeping allowed when burning coins, to keep the tx " + if not isinstance(wallet_service.wallet, FidelityBondMixin):
"small. Tip: use the coin control feature to freeze utxos") log.error("Only fidelity bond wallets can burn coins")
return return
if answeryes:
log.error("Burning coins not allowed without asking for confirmation")
return
if mixdepth != FidelityBondMixin.FIDELITY_BOND_MIXDEPTH:
log.error("Burning coins only allowed from mixdepth " + str(
FidelityBondMixin.FIDELITY_BOND_MIXDEPTH))
return
if amount != 0:
log.error("Only sweeping allowed when burning coins, to keep "
"the tx small. Tip: use the coin control feature to "
"freeze utxos")
return
# if the output is of a script type not currently
# handled by our wallet code, we can't use information
# to help us calculate fees, but fall back to default.
# This is represented by a return value `None`.
# Note that this does *not* imply we accept any nonstandard
# output script, because we already called `validate_address`.
outtypes.append(wallet_service.get_outtype(destination))
total_outputs_val += amount
txtype = wallet_service.get_txtype() txtype = wallet_service.get_txtype()
# if the output is of a script type not currently if is_sweep:
# handled by our wallet code, we can't use information
# to help us calculate fees, but fall back to default.
# This is represented by a return value `None`.
# Note that this does *not* imply we accept any nonstandard
# output script, because we already called `validate_address`.
outtype = wallet_service.get_outtype(destination)
if amount == 0:
#doing a sweep #doing a sweep
destination = dest_and_amounts[0][0]
amount = dest_and_amounts[0][1]
utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth] utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth]
if utxos == {}: if utxos == {}:
log.error( log.error(
"There are no available utxos in mixdepth: " + str(mixdepth) + ", quitting.") f"There are no available utxos in mixdepth {mixdepth}, "
"quitting.")
return return
total_inputs_val = sum([va['value'] for u, va in utxos.items()]) total_inputs_val = sum([va['value'] for u, va in utxos.items()])
script_types = get_utxo_scripts(wallet_service.wallet, utxos) script_types = get_utxo_scripts(wallet_service.wallet, utxos)
fee_est = estimate_tx_fee(len(utxos), 1, txtype=script_types, outtype=outtype) fee_est = estimate_tx_fee(len(utxos), 1, txtype=script_types,
outs = [{"address": destination, "value": total_inputs_val - fee_est}] outtype=outtypes[0])
outs = [{"address": destination,
"value": total_inputs_val - fee_est}]
else: else:
change_type = txtype
if custom_change_addr: if custom_change_addr:
change_type = wallet_service.get_outtype(custom_change_addr) change_type = wallet_service.get_outtype(custom_change_addr)
if change_type is None: if change_type is None:
# we don't recognize this type; best we can do is revert to default, # we don't recognize this type; best we can do is revert to
# even though it may be inaccurate: # default, even though it may be inaccurate:
change_type = txtype change_type = txtype
if outtype is None: else:
change_type = txtype
if outtypes[0] is None:
# we don't recognize the destination script type, # we don't recognize the destination script type,
# so set it as the same as the change (which will usually # so set it as the same as the change (which will usually
# be the same as the spending wallet, but see above for custom) # be the same as the spending wallet, but see above for custom)
# Notice that this is handled differently to the sweep case above, # Notice that this is handled differently to the sweep case above,
# because we must use a list - there is more than one output # because we must use a list - there is more than one output
outtype = change_type outtypes[0] = change_type
outtypes = [change_type, outtype] outtypes.append(change_type)
# not doing a sweep; we will have change. # not doing a sweep; we will have change.
# 8 inputs to be conservative; note we cannot account for the possibility # 8 inputs to be conservative; note we cannot account for the possibility
# of non-standard input types at this point. # of non-standard input types at this point.
initial_fee_est = estimate_tx_fee(8, 2, txtype=txtype, outtype=outtypes) initial_fee_est = estimate_tx_fee(8, len(dest_and_amounts) + 1,
txtype=txtype, outtype=outtypes)
utxos = wallet_service.select_utxos(mixdepth, amount + initial_fee_est, utxos = wallet_service.select_utxos(mixdepth, amount + initial_fee_est,
includeaddr=True) includeaddr=True)
script_types = get_utxo_scripts(wallet_service.wallet, utxos) script_types = get_utxo_scripts(wallet_service.wallet, utxos)
if len(utxos) < 8: if len(utxos) < 8:
fee_est = estimate_tx_fee(len(utxos), 2, txtype=script_types, outtype=outtypes) fee_est = estimate_tx_fee(len(utxos), len(dest_and_amounts) + 1,
txtype=script_types, outtype=outtypes)
else: else:
fee_est = initial_fee_est fee_est = initial_fee_est
total_inputs_val = sum([va['value'] for u, va in utxos.items()]) total_inputs_val = sum([va['value'] for u, va in utxos.items()])
changeval = total_inputs_val - fee_est - amount changeval = total_inputs_val - fee_est - total_outputs_val
outs = [{"value": amount, "address": destination}] outs = []
change_addr = wallet_service.get_internal_addr(mixdepth) if custom_change_addr is None \ for out in dest_and_amounts:
else custom_change_addr outs.append({"value": out[1], "address": out[0]})
change_addr = wallet_service.get_internal_addr(mixdepth) \
if custom_change_addr is None else custom_change_addr
outs.append({"value": changeval, "address": change_addr}) outs.append({"value": changeval, "address": change_addr})
#compute transaction locktime, has special case for spending timelocked coins #compute transaction locktime, has special case for spending timelocked coins
@ -170,9 +196,10 @@ def direct_send(wallet_service: WalletService, amount: int, mixdepth: int,
#Now ready to construct transaction #Now ready to construct transaction
log.info("Using a fee of: " + amount_to_str(fee_est) + ".") log.info("Using a fee of: " + amount_to_str(fee_est) + ".")
if amount != 0: if not is_sweep:
log.info("Using a change value of: " + amount_to_str(changeval) + ".") log.info("Using a change value of: " + amount_to_str(changeval) + ".")
tx = make_shuffled_tx(list(utxos.keys()), outs, 2, tx_locktime) tx = make_shuffled_tx(list(utxos.keys()), outs,
version=2, locktime=tx_locktime)
if optin_rbf: if optin_rbf:
for inp in tx.vin: for inp in tx.vin:

6
src/jmclient/wallet_rpc.py

@ -796,9 +796,11 @@ class JMWalletDaemon(Service):
try: try:
tx = direct_send(self.services["wallet"], tx = direct_send(self.services["wallet"],
int(payment_info_json["amount_sats"]),
int(payment_info_json["mixdepth"]), int(payment_info_json["mixdepth"]),
destination=payment_info_json["destination"], [(
payment_info_json["destination"],
int(payment_info_json["amount_sats"])
)],
return_transaction=True, answeryes=True) return_transaction=True, answeryes=True)
jm_single().config.set("POLICY", "tx_fees", jm_single().config.set("POLICY", "tx_fees",
self.default_policy_tx_fees) self.default_policy_tx_fees)

12
test/jmclient/test_psbt_wallet.py

@ -95,10 +95,11 @@ def test_create_and_sign_psbt_with_legacy(setup_psbt_wallet):
legacy_addr = bitcoin.CCoinAddress.from_scriptPubKey( legacy_addr = bitcoin.CCoinAddress.from_scriptPubKey(
bitcoin.pubkey_to_p2pkh_script( bitcoin.pubkey_to_p2pkh_script(
bitcoin.privkey_to_pubkey(b"\x01"*33))) bitcoin.privkey_to_pubkey(b"\x01"*33)))
tx = direct_send(wallet_service, bitcoin.coins_to_satoshi(0.3), 0, tx = direct_send(wallet_service, 0,
str(legacy_addr), accept_callback=dummy_accept_callback, [(str(legacy_addr), bitcoin.coins_to_satoshi(0.3))],
info_callback=dummy_info_callback, accept_callback=dummy_accept_callback,
return_transaction=True) info_callback=dummy_info_callback,
return_transaction=True)
assert tx assert tx
# this time we will have one utxo worth <~ 0.7 # this time we will have one utxo worth <~ 0.7
my_utxos = wallet_service.select_utxos(0, bitcoin.coins_to_satoshi(0.5)) my_utxos = wallet_service.select_utxos(0, bitcoin.coins_to_satoshi(0.5))
@ -277,7 +278,8 @@ def test_payjoin_workflow(setup_psbt_wallet, payment_amt, wallet_cls_sender,
# ************** # **************
# create a normal tx from the sender wallet: # create a normal tx from the sender wallet:
payment_psbt = direct_send(wallet_s, payment_amt, 0, destaddr, payment_psbt = direct_send(wallet_s, 0,
[(destaddr, payment_amt)],
accept_callback=dummy_accept_callback, accept_callback=dummy_accept_callback,
info_callback=dummy_info_callback, info_callback=dummy_info_callback,
with_final_psbt=True) with_final_psbt=True)

7
test/jmclient/test_snicker.py

@ -41,8 +41,11 @@ def test_snicker_e2e(setup_snicker, nw, wallet_structures,
wallet_p = wallets[1]['wallet'] wallet_p = wallets[1]['wallet']
# next, create a tx from the receiver wallet # next, create a tx from the receiver wallet
our_destn_script = wallet_r.get_new_script(1, BaseWallet.ADDRESS_TYPE_INTERNAL) our_destn_script = wallet_r.get_new_script(1, BaseWallet.ADDRESS_TYPE_INTERNAL)
tx = direct_send(wallet_r, btc.coins_to_satoshi(0.3), 0, tx = direct_send(wallet_r, 0,
wallet_r.script_to_addr(our_destn_script), [(
wallet_r.script_to_addr(our_destn_script),
btc.coins_to_satoshi(0.3)
)],
accept_callback=dummy_accept_callback, accept_callback=dummy_accept_callback,
info_callback=dummy_info_callback, info_callback=dummy_info_callback,
return_transaction=True) return_transaction=True)

4
test/jmclient/test_tx_creation.py

@ -163,8 +163,8 @@ def test_spend_then_rbf(setup_tx_creation):
# While `direct_send` usually encapsulates utxo selection # While `direct_send` usually encapsulates utxo selection
# for user, here we need to know what was chosen, hence # for user, here we need to know what was chosen, hence
# we return the transaction object, not directly broadcast. # we return the transaction object, not directly broadcast.
tx1 = direct_send(wallet_service, amount, 0, tx1 = direct_send(wallet_service, 0, [(destn, amount)],
destn, answeryes=True, answeryes=True,
return_transaction=True) return_transaction=True)
assert tx1 assert tx1
# record the utxos for reuse: # record the utxos for reuse:

Loading…
Cancel
Save