#!/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")