You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
401 lines
18 KiB
401 lines
18 KiB
#!/usr/bin/env python3 |
|
|
|
""" |
|
A sample implementation of a single coinjoin script, |
|
adapted from `sendpayment.py` in Joinmarket-Org/joinmarket. |
|
For notes, see scripts/README.md; in particular, note the use |
|
of "schedules" with the -S flag. |
|
""" |
|
|
|
import sys |
|
from twisted.internet import reactor |
|
import pprint |
|
|
|
from jmclient import Taker, load_program_config, get_schedule,\ |
|
JMClientProtocolFactory, start_reactor, validate_address, is_burn_destination, \ |
|
jm_single, estimate_tx_fee, direct_send, WalletService,\ |
|
open_test_wallet_maybe, get_wallet_path, NO_ROUNDING, \ |
|
get_sendpayment_parser, get_max_cj_fee_values, check_regtest, \ |
|
parse_payjoin_setup, send_payjoin, general_custom_change_warning, \ |
|
nonwallet_custom_change_warning, sweep_custom_change_warning, \ |
|
EngineError, check_and_start_tor |
|
from twisted.python.log import startLogging |
|
from jmbase.support import get_log, jmprint, \ |
|
EXIT_FAILURE, EXIT_ARGERROR, cli_prompt_user_yesno |
|
|
|
import jmbitcoin as btc |
|
|
|
log = get_log() |
|
|
|
#CLI specific, so relocated here (not used by tumbler) |
|
def pick_order(orders, n): #pragma: no cover |
|
jmprint("Considered orders:", "info") |
|
for i, o in enumerate(orders): |
|
jmprint(" %2d. %20s, CJ fee: %6s, tx fee: %6d, FB value: %f" % |
|
(i, o[0]['counterparty'], str(o[0]['cjfee']), o[0]['txfee'], |
|
o[0]['fidelity_bond_value']), "info") |
|
pickedOrderIndex = -1 |
|
if i == 0: |
|
jmprint("Only one possible pick, picking it.", "info") |
|
return orders[0] |
|
while pickedOrderIndex == -1: |
|
try: |
|
pickedOrderIndex = int(input('Pick an order between 0 and ' + |
|
str(i) + ': ')) |
|
except ValueError: |
|
pickedOrderIndex = -1 |
|
continue |
|
|
|
if 0 <= pickedOrderIndex < len(orders): |
|
return orders[pickedOrderIndex] |
|
pickedOrderIndex = -1 |
|
|
|
def main(): |
|
parser = get_sendpayment_parser() |
|
(options, args) = parser.parse_args() |
|
load_program_config(config_path=options.datadir) |
|
|
|
if options.schedule == '': |
|
if ((len(args) < 2) or |
|
(btc.is_bip21_uri(args[1]) and len(args) != 2) or |
|
(not btc.is_bip21_uri(args[1]) and len(args) != 3)): |
|
parser.error("Joinmarket sendpayment (coinjoin) needs arguments:" |
|
" wallet, amount, destination address or wallet, bitcoin_uri.") |
|
sys.exit(EXIT_ARGERROR) |
|
|
|
check_and_start_tor() |
|
|
|
#without schedule file option, use the arguments to create a schedule |
|
#of a single transaction |
|
sweeping = False |
|
bip78url = None |
|
if options.schedule == '': |
|
if btc.is_bip21_uri(args[1]): |
|
parsed = btc.decode_bip21_uri(args[1]) |
|
try: |
|
amount = parsed['amount'] |
|
except KeyError: |
|
parser.error("Given BIP21 URI does not contain amount.") |
|
sys.exit(EXIT_ARGERROR) |
|
destaddr = parsed['address'] |
|
if "pj" in parsed: |
|
# note that this is a URL; its validity |
|
# checking is deferred to twisted.web.client.Agent |
|
bip78url = parsed["pj"] |
|
# setting makercount only for fee sanity check. |
|
# note we ignore any user setting and enforce N=0, |
|
# as this is a flag in the code for a non-JM coinjoin; |
|
# for the fee sanity check, note that BIP78 currently |
|
# will only allow small fee changes, so N=0 won't |
|
# be very inaccurate. |
|
jmprint("Attempting to pay via payjoin.", "info") |
|
options.makercount = 0 |
|
else: |
|
amount = btc.amount_to_sat(args[1]) |
|
if amount == 0: |
|
sweeping = True |
|
destaddr = args[2] |
|
mixdepth = options.mixdepth |
|
if len(args) > 2 and btc.is_bip21_uri(args[2]): |
|
parsed = btc.decode_bip21_uri(args[2]) |
|
if 'amount' in parsed: |
|
parser.error("Specify amount as a separate argument or amount in BIP21 URI, not both.") |
|
sys.exit(EXIT_ARGERROR) |
|
destaddr = parsed['address'] |
|
addr_valid, errormsg = validate_address(destaddr) |
|
command_to_burn = (is_burn_destination(destaddr) and sweeping and |
|
options.makercount == 0) |
|
if not addr_valid and not command_to_burn: |
|
jmprint('ERROR: Address invalid. ' + errormsg, "error") |
|
if is_burn_destination(destaddr): |
|
jmprint("The required options for burning coins are zero makers" |
|
+ " (-N 0), sweeping (amount = 0) and not using BIP78 Payjoin", "info") |
|
sys.exit(EXIT_ARGERROR) |
|
if sweeping == False and options.makercount > 0 and amount < jm_single().DUST_THRESHOLD: |
|
jmprint('ERROR: Amount ' + btc.amount_to_str(amount) + |
|
' is below dust threshold ' + |
|
btc.amount_to_str(jm_single().DUST_THRESHOLD) + '.', "error") |
|
sys.exit(EXIT_ARGERROR) |
|
if (options.makercount != 0 and |
|
options.makercount < jm_single().config.getint( |
|
"POLICY", "minimum_makers")): |
|
jmprint('ERROR: Maker count ' + str(options.makercount) + |
|
' below minimum_makers (' + str(jm_single().config.getint( |
|
"POLICY", "minimum_makers")) + ') in joinmarket.cfg.', |
|
"error") |
|
sys.exit(EXIT_ARGERROR) |
|
schedule = [[options.mixdepth, amount, options.makercount, |
|
destaddr, 0.0, NO_ROUNDING, 0]] |
|
else: |
|
if len(args) > 1: |
|
parser.error("Schedule files are not compatible with " |
|
"payment destination/amount arguments.") |
|
sys.exit(EXIT_ARGERROR) |
|
result, schedule = get_schedule(options.schedule) |
|
if not result: |
|
log.error("Failed to load schedule file, quitting. Check the syntax.") |
|
log.error("Error was: " + str(schedule)) |
|
sys.exit(EXIT_FAILURE) |
|
mixdepth = 0 |
|
for s in schedule: |
|
if s[1] == 0: |
|
sweeping = True |
|
#only used for checking the maximum mixdepth required |
|
mixdepth = max([mixdepth, s[0]]) |
|
|
|
wallet_name = args[0] |
|
|
|
check_regtest() |
|
|
|
if options.pickorders: |
|
chooseOrdersFunc = pick_order |
|
if sweeping: |
|
jmprint('WARNING: You may have to pick offers multiple times', "warning") |
|
jmprint('WARNING: due to manual offer picking while sweeping', "warning") |
|
else: |
|
chooseOrdersFunc = options.order_choose_fn |
|
|
|
# If tx_fees are set manually by CLI argument, override joinmarket.cfg: |
|
if int(options.txfee) > 0: |
|
if jm_single().bc_interface.fee_per_kb_has_been_manually_set( |
|
options.txfee): |
|
absurd_fee = jm_single().config.getint("POLICY", |
|
"absurd_fee_per_kb") |
|
tx_fees_factor = jm_single().config.getfloat("POLICY", |
|
"tx_fees_factor") |
|
max_potential_txfee = int(max(options.txfee, |
|
options.txfee * float(1 + tx_fees_factor))) |
|
if max_potential_txfee > absurd_fee: |
|
jmprint( |
|
"WARNING: Manually specified Bitcoin transaction fee " |
|
f"{btc.fee_per_kb_to_str(options.txfee)} can be " |
|
"randomized up to " |
|
f"{btc.fee_per_kb_to_str(max_potential_txfee)}, " |
|
"above absurd value " |
|
f"{btc.fee_per_kb_to_str(absurd_fee)}.", |
|
"warning") |
|
if not cli_prompt_user_yesno("Still continue?"): |
|
sys.exit("Aborted by user.") |
|
jm_single().config.set("POLICY", "absurd_fee_per_kb", |
|
str(max_potential_txfee)) |
|
jm_single().config.set("POLICY", "tx_fees", str(options.txfee)) |
|
|
|
maxcjfee = (1, float('inf')) |
|
if not options.pickorders and options.makercount != 0: |
|
maxcjfee = get_max_cj_fee_values(jm_single().config, options) |
|
log.info("Using maximum coinjoin fee limits per maker of {:.4%}, {} " |
|
"".format(maxcjfee[0], btc.amount_to_str(maxcjfee[1]))) |
|
|
|
log.info('starting sendpayment') |
|
|
|
max_mix_depth = max([mixdepth, options.amtmixdepths - 1]) |
|
|
|
wallet_path = get_wallet_path(wallet_name, None) |
|
wallet = open_test_wallet_maybe( |
|
wallet_path, wallet_name, max_mix_depth, |
|
wallet_password_stdin=options.wallet_password_stdin, |
|
gap_limit=options.gaplimit) |
|
wallet_service = WalletService(wallet) |
|
if wallet_service.rpc_error: |
|
sys.exit(EXIT_FAILURE) |
|
# in this script, we need the wallet synced before |
|
# logic processing for some paths, so do it now: |
|
while not wallet_service.synced: |
|
wallet_service.sync_wallet(fast=not options.recoversync) |
|
# the sync call here will now be a no-op: |
|
wallet_service.startService() |
|
|
|
# Dynamically estimate a realistic fee, for coinjoins. |
|
# At this point we do not know even the number of our own inputs, so |
|
# we guess conservatively with 2 inputs and 2 outputs each. |
|
if options.makercount != 0: |
|
fee_per_cp_guess = estimate_tx_fee(2, 2, |
|
txtype=wallet_service.get_txtype()) |
|
log.debug("Estimated miner/tx fee for each cj participant: " + |
|
btc.amount_to_str(fee_per_cp_guess)) |
|
|
|
# From the estimated tx fees, check if the expected amount is a |
|
# significant value compared the the cj amount; currently enabled |
|
# only for single join (the predominant, non-advanced case) |
|
if options.schedule == '' and options.makercount != 0: |
|
total_cj_amount = amount |
|
if total_cj_amount == 0: |
|
total_cj_amount = wallet_service.get_balance_by_mixdepth()[options.mixdepth] |
|
if total_cj_amount == 0: |
|
raise ValueError("No confirmed coins in the selected mixdepth. Quitting") |
|
exp_tx_fees_ratio = ((1 + options.makercount) * fee_per_cp_guess) / total_cj_amount |
|
if exp_tx_fees_ratio > 0.05: |
|
jmprint('WARNING: Expected bitcoin network miner fees for this coinjoin' |
|
' amount are roughly {:.1%}'.format(exp_tx_fees_ratio), "warning") |
|
print('You might want to modify your tx_fee settings in joinmarket.cfg.') |
|
if not cli_prompt_user_yesno('Still continue?'): |
|
sys.exit('Aborted by user.') |
|
else: |
|
log.info("Estimated miner/tx fees for this coinjoin amount: {:.1%}" |
|
.format(exp_tx_fees_ratio)) |
|
|
|
custom_change = None |
|
if options.customchange != '': |
|
addr_valid, errormsg = validate_address(options.customchange) |
|
if not addr_valid: |
|
parser.error( |
|
"The custom change address provided is not valid\n{}".format( |
|
errormsg)) |
|
sys.exit(EXIT_ARGERROR) |
|
custom_change = options.customchange |
|
if destaddr and custom_change == destaddr: |
|
parser.error("The custom change address cannot be the same as the " |
|
"destination address.") |
|
sys.exit(EXIT_ARGERROR) |
|
if sweeping: |
|
parser.error(sweep_custom_change_warning) |
|
sys.exit(EXIT_ARGERROR) |
|
if bip78url: |
|
parser.error("Custom change is not currently supported " |
|
"with Payjoin. Please retry without a custom change address.") |
|
sys.exit(EXIT_ARGERROR) |
|
if options.makercount > 0: |
|
if not options.answeryes and \ |
|
not cli_prompt_user_yesno(general_custom_change_warning): |
|
sys.exit(EXIT_ARGERROR) |
|
engine_recognized = True |
|
try: |
|
change_addr_type = wallet_service.get_outtype(custom_change) |
|
except EngineError: |
|
engine_recognized = False |
|
if (not engine_recognized) or ( |
|
change_addr_type != wallet_service.get_txtype()): |
|
if not options.answeryes and \ |
|
not cli_prompt_user_yesno(nonwallet_custom_change_warning): |
|
sys.exit(EXIT_ARGERROR) |
|
|
|
if options.makercount == 0 and not bip78url: |
|
tx = direct_send(wallet_service, mixdepth, |
|
[(destaddr, amount)], |
|
options.answeryes, |
|
with_final_psbt=options.with_psbt, |
|
optin_rbf=not options.no_rbf, |
|
custom_change_addr=custom_change, |
|
change_label=options.changelabel) |
|
if options.with_psbt: |
|
log.info("This PSBT is fully signed and can be sent externally for " |
|
"broadcasting:") |
|
log.info(tx.to_base64()) |
|
return |
|
|
|
if wallet.get_txtype() == 'p2pkh': |
|
jmprint("Only direct sends (use -N 0) are supported for " |
|
"legacy (non-segwit) wallets.", "error") |
|
sys.exit(EXIT_ARGERROR) |
|
|
|
def filter_orders_callback(orders_fees, cjamount): |
|
orders, total_cj_fee = orders_fees |
|
log.info("Chose these orders: " +pprint.pformat(orders)) |
|
log.info('total cj fee = ' + str(total_cj_fee)) |
|
total_fee_pc = 1.0 * total_cj_fee / cjamount |
|
log.info('total coinjoin fee = ' + str(float('%.3g' % ( |
|
100.0 * total_fee_pc))) + '%') |
|
WARNING_THRESHOLD = 0.02 # 2% |
|
if total_fee_pc > WARNING_THRESHOLD: |
|
log.info('\n'.join(['=' * 60] * 3)) |
|
log.info('WARNING ' * 6) |
|
log.info('\n'.join(['=' * 60] * 1)) |
|
log.info('OFFERED COINJOIN FEE IS UNUSUALLY HIGH. DOUBLE/TRIPLE CHECK.') |
|
log.info('\n'.join(['=' * 60] * 1)) |
|
log.info('WARNING ' * 6) |
|
log.info('\n'.join(['=' * 60] * 3)) |
|
if not options.answeryes: |
|
if not cli_prompt_user_yesno('Send with these orders?'): |
|
return False |
|
return True |
|
|
|
def taker_finished(res, fromtx=False, waittime=0.0, txdetails=None): |
|
if fromtx == "unconfirmed": |
|
#If final entry, stop *here*, don't wait for confirmation |
|
if taker.schedule_index + 1 == len(taker.schedule): |
|
reactor.stop() |
|
return |
|
if fromtx: |
|
if res: |
|
txd, txid = txdetails |
|
reactor.callLater(waittime*60, |
|
clientfactory.getClient().clientStart) |
|
else: |
|
#a transaction failed; we'll try to repeat without the |
|
#troublemakers. |
|
#If this error condition is reached from Phase 1 processing, |
|
#and there are less than minimum_makers honest responses, we |
|
#just give up (note that in tumbler we tweak and retry, but |
|
#for sendpayment the user is "online" and so can manually |
|
#try again). |
|
#However if the error is in Phase 2 and we have minimum_makers |
|
#or more responses, we do try to restart with the honest set, here. |
|
if taker.latest_tx is None: |
|
#can only happen with < minimum_makers; see above. |
|
log.info("A transaction failed but there are insufficient " |
|
"honest respondants to continue; giving up.") |
|
reactor.stop() |
|
return |
|
#This is Phase 2; do we have enough to try again? |
|
taker.add_honest_makers(list(set( |
|
taker.maker_utxo_data.keys()).symmetric_difference( |
|
set(taker.nonrespondants)))) |
|
if len(taker.honest_makers) < jm_single().config.getint( |
|
"POLICY", "minimum_makers"): |
|
log.info("Too few makers responded honestly; " |
|
"giving up this attempt.") |
|
reactor.stop() |
|
return |
|
jmprint("We failed to complete the transaction. The following " |
|
"makers responded honestly: " + str(taker.honest_makers) +\ |
|
", so we will retry with them.", "warning") |
|
#Now we have to set the specific group we want to use, and hopefully |
|
#they will respond again as they showed honesty last time. |
|
#we must reset the number of counterparties, as well as fix who they |
|
#are; this is because the number is used to e.g. calculate fees. |
|
#cleanest way is to reset the number in the schedule before restart. |
|
taker.schedule[taker.schedule_index][2] = len(taker.honest_makers) |
|
log.info("Retrying with: " + str(taker.schedule[ |
|
taker.schedule_index][2]) + " counterparties.") |
|
#rewind to try again (index is incremented in Taker.initialize()) |
|
taker.schedule_index -= 1 |
|
taker.set_honest_only(True) |
|
reactor.callLater(5.0, clientfactory.getClient().clientStart) |
|
else: |
|
if not res: |
|
log.info("Did not complete successfully, shutting down") |
|
#Should usually be unreachable, unless conf received out of order; |
|
#because we should stop on 'unconfirmed' for last (see above) |
|
else: |
|
log.info("All transactions completed correctly") |
|
reactor.stop() |
|
|
|
nodaemon = jm_single().config.getint("DAEMON", "no_daemon") |
|
daemon = True if nodaemon == 1 else False |
|
dhost = jm_single().config.get("DAEMON", "daemon_host") |
|
dport = jm_single().config.getint("DAEMON", "daemon_port") |
|
if bip78url: |
|
# TODO sanity check wallet type is segwit |
|
manager = parse_payjoin_setup(args[1], wallet_service, options.mixdepth) |
|
reactor.callWhenRunning(send_payjoin, manager) |
|
# JM is default, so must be switched off explicitly in this call: |
|
start_reactor(dhost, dport, bip78=True, jm_coinjoin=False, daemon=daemon) |
|
return |
|
|
|
else: |
|
taker = Taker(wallet_service, |
|
schedule, |
|
order_chooser=chooseOrdersFunc, |
|
max_cj_fee=maxcjfee, |
|
callbacks=(filter_orders_callback, None, taker_finished), |
|
custom_change_address=custom_change, |
|
change_label=options.changelabel) |
|
clientfactory = JMClientProtocolFactory(taker) |
|
|
|
if jm_single().config.get("BLOCKCHAIN", "network") == "regtest": |
|
startLogging(sys.stdout) |
|
start_reactor(dhost, dport, clientfactory, daemon=daemon) |
|
|
|
if __name__ == "__main__": |
|
main() |
|
jmprint('done', "success")
|
|
|