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.
210 lines
8.9 KiB
210 lines
8.9 KiB
#!/usr/bin/env python3 |
|
|
|
description="""Make fake SNICKER transactions to aid discovery. |
|
|
|
Use this script to send money to yourself in a transaction which |
|
fits the format of SNICKER v1 (so it will have two equal sized |
|
outputs and a change output, also obeying the other minor rules |
|
for SNICKER). |
|
|
|
Having done this your transaction will be picked up by blockchain |
|
scanners looking for the SNICKER "fingerprint", allowing them |
|
to propose coinjoins with your coins. |
|
|
|
The transaction is generated with at least TWO utxos from your chosen |
|
source mixdepth/account (-m), so it must contain at least two. |
|
The reason for using one account, not two, is to prevent violating |
|
the principle of not co-spending from different accounts; even though |
|
this is a simulated coinjoin, it may be deducible that it is only really |
|
a *signalling* fake coinjoin, so it is better not to violate the principle. |
|
""" |
|
|
|
import sys |
|
import random |
|
from optparse import OptionParser |
|
from jmbase import bintohex, jmprint, EXIT_ARGERROR, EXIT_FAILURE |
|
import jmbitcoin as btc |
|
from jmclient import (jm_single, load_program_config, check_regtest, |
|
estimate_tx_fee, add_base_options, get_wallet_path, |
|
open_test_wallet_maybe, WalletService, JMPluginService) |
|
from jmclient.support import select_greedy, NotEnoughFundsException |
|
from jmclient.configure import get_log |
|
|
|
log = get_log() |
|
|
|
def main(): |
|
parser = OptionParser( |
|
usage= |
|
'usage: %prog [options] walletname', |
|
description=description |
|
) |
|
add_base_options(parser) |
|
parser.add_option('-m', |
|
'--mixdepth', |
|
action='store', |
|
type='int', |
|
dest='mixdepth', |
|
help='mixdepth/account, default 0', |
|
default=0) |
|
parser.add_option( |
|
'-g', |
|
'--gap-limit', |
|
action='store', |
|
type='int', |
|
dest='gaplimit', |
|
default = 6, |
|
help='gap limit for Joinmarket wallet, default 6.' |
|
) |
|
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('-N', |
|
'--net-transfer', |
|
action='store', |
|
type='int', |
|
dest='net_transfer', |
|
help='how many sats are sent to the "receiver", default randomised.', |
|
default=-1000001) |
|
(options, args) = parser.parse_args() |
|
snicker_plugin = JMPluginService("SNICKER") |
|
load_program_config(config_path=options.datadir, |
|
plugin_services=[snicker_plugin]) |
|
if len(args) != 1: |
|
log.error("Invalid arguments, see --help") |
|
sys.exit(EXIT_ARGERROR) |
|
wallet_name = args[0] |
|
check_regtest() |
|
# 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)) |
|
max_mix_depth = max([options.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) |
|
snicker_plugin.start_plugin_logging(wallet_service) |
|
# 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() |
|
fee_est = estimate_tx_fee(2, 3, txtype=wallet_service.get_txtype()) |
|
|
|
# first, order the utxos in the mixepth by size. Then (this is the |
|
# simplest algorithm; we could be more sophisticated), choose the |
|
# *second* largest utxo as the receiver utxo; this ensures that we |
|
# have enough for the proposer to cover. We consume utxos greedily, |
|
# meaning we'll at least some of the time, be consolidating. |
|
utxo_dict = wallet_service.get_utxos_by_mixdepth()[options.mixdepth] |
|
if not len(utxo_dict) >= 2: |
|
log.error("Cannot create fake SNICKER tx without at least two utxos, quitting") |
|
sys.exit(EXIT_ARGERROR) |
|
# sort utxos by size |
|
sorted_utxos = sorted(list(utxo_dict.keys()), |
|
key=lambda k: utxo_dict[k]['value'], |
|
reverse=True) |
|
# receiver is the second largest: |
|
receiver_utxo = sorted_utxos[1] |
|
receiver_utxo_val = utxo_dict[receiver_utxo] |
|
# gather the other utxos into a list to select from: |
|
nonreceiver_utxos = [sorted_utxos[0]] + sorted_utxos[2:] |
|
# get the net transfer in our fake coinjoin: |
|
if options.net_transfer < -1000001: |
|
log.error("Net transfer must be greater than negative 1M sats") |
|
sys.exit(EXIT_ARGERROR) |
|
if options.net_transfer == -1000001: |
|
# default; low-ish is more realistic and avoids problems |
|
# with dusty utxos |
|
options.net_transfer = random.randint(-1000, 1000) |
|
|
|
# select enough to cover: receiver value + fee + transfer + breathing room |
|
# we select relatively greedily to support consolidation, since |
|
# this transaction does not pretend to isolate the coins. |
|
try: |
|
available = [{'utxo': utxo, 'value': utxo_dict[utxo]["value"]} |
|
for utxo in nonreceiver_utxos] |
|
# selection algos return [{"utxo":..,"value":..}]: |
|
prop_utxos = {x["utxo"] for x in select_greedy(available, |
|
receiver_utxo_val["value"] + fee_est + options.net_transfer + 1000)} |
|
prop_utxos = list(prop_utxos) |
|
prop_utxo_vals = [utxo_dict[prop_utxo] for prop_utxo in prop_utxos] |
|
except NotEnoughFundsException as e: |
|
log.error(repr(e)) |
|
sys.exit(EXIT_FAILURE) |
|
|
|
# Due to the fake nature of this transaction, and its distinguishability |
|
# (not only in trivial output pattern, but also in subset-sum), there |
|
# is little advantage in making it use different output mixdepths, so |
|
# here to prevent fragmentation, everything is kept in the same mixdepth. |
|
receiver_addr, proposer_addr, change_addr = (wallet_service.script_to_addr( |
|
wallet_service.get_new_script(options.mixdepth, 1)) for _ in range(3)) |
|
# persist index update: |
|
wallet_service.save_wallet() |
|
outputs = btc.construct_snicker_outputs( |
|
sum([x["value"] for x in prop_utxo_vals]), |
|
receiver_utxo_val["value"], |
|
receiver_addr, |
|
proposer_addr, |
|
change_addr, |
|
fee_est, |
|
options.net_transfer) |
|
tx = btc.make_shuffled_tx(prop_utxos + [receiver_utxo], |
|
outputs, |
|
version=2, |
|
locktime=0) |
|
# before signing, check we satisfied the criteria, otherwise |
|
# this is pointless! |
|
if not btc.is_snicker_tx(tx): |
|
log.error("Code error, created non-SNICKER tx, not signing.") |
|
sys.exit(EXIT_FAILURE) |
|
|
|
# sign all inputs |
|
# scripts: {input_index: (output_script, amount)} |
|
our_inputs = {} |
|
for index, ins in enumerate(tx.vin): |
|
utxo = (ins.prevout.hash[::-1], ins.prevout.n) |
|
script = utxo_dict[utxo]['script'] |
|
amount = utxo_dict[utxo]['value'] |
|
our_inputs[index] = (script, amount) |
|
success, msg = wallet_service.sign_tx(tx, our_inputs) |
|
if not success: |
|
log.error("Failed to sign transaction: " + msg) |
|
sys.exit(EXIT_FAILURE) |
|
# TODO condition on automatic brdcst or not |
|
if not jm_single().bc_interface.pushtx(tx.serialize()): |
|
# this represents an error about state (or conceivably, |
|
# an ultra-short window in which the spent utxo was |
|
# consumed in another transaction), but not really |
|
# an internal logic error, so we do NOT return False |
|
log.error("Failed to broadcast fake SNICKER coinjoin: " +\ |
|
bintohex(tx.GetTxid()[::-1])) |
|
log.info(btc.human_readable_transaction(tx)) |
|
sys.exit(EXIT_FAILURE) |
|
log.info("Successfully broadcast fake SNICKER coinjoin: " +\ |
|
bintohex(tx.GetTxid()[::-1])) |
|
|
|
if __name__ == "__main__": |
|
main() |
|
jmprint('done', "success")
|
|
|