diff --git a/README.md b/README.md index 6207f69..bc79d5c 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ The fields are as follows: - `anon_score_target` is the target anon score of the wallet. - `redcoin_isolation` is a boolean value indicating whether the wallet should use redcoin isolation. +## Engine +You can run the simulation with different CoinJoin protocols. Currently, Wasabi and Joinmarket are supported. +The default protocol is Wasabi. To run the simulation with Joinmarket, use the `--engine joinmarket` option. + ## Advanced usage diff --git a/containers/btc-node/mine.sh b/containers/btc-node/mine.sh index 76fd021..be44577 100755 --- a/containers/btc-node/mine.sh +++ b/containers/btc-node/mine.sh @@ -17,6 +17,6 @@ fi while true do sleep $(($RANDOM % 60 + 30)) - ADDR=$(curl -s -u user:password --data-binary '{"jsonrpc": "2.0", "method": "getnewaddress", "params": ["wallet"]}' -H 'content-type: text/plain;' http://localhost:18443 | jq -r '.result') - curl -s -u user:password --data-binary "{\"jsonrpc\": \"2.0\", \"method\": \"generatetoaddress\", \"params\": [1, \"$ADDR\"]}" -H 'content-type: text/plain;' http://localhost:18443 > /dev/null + ADDR=$(curl -s -u user:password --data-binary '{"jsonrpc": "2.0", "method": "getnewaddress", "params": ["wallet"]}' -H 'content-type: text/plain;' http://localhost:18443/wallet/wallet | jq -r '.result') + curl -s -u user:password --data-binary "{\"jsonrpc\": \"2.0\", \"method\": \"generatetoaddress\", \"params\": [1, \"$ADDR\"]}" -H 'content-type: text/plain;' http://localhost:18443> /dev/null done diff --git a/containers/irc-server/Dockerfile b/containers/irc-server/Dockerfile new file mode 100644 index 0000000..5654515 --- /dev/null +++ b/containers/irc-server/Dockerfile @@ -0,0 +1,3 @@ +FROM inspircd/inspircd-docker:latest + +COPY inspircd.conf /inspircd/conf/inspircd.conf diff --git a/containers/irc-server/inspircd.conf b/containers/irc-server/inspircd.conf new file mode 100644 index 0000000..0597b06 --- /dev/null +++ b/containers/irc-server/inspircd.conf @@ -0,0 +1,46 @@ + + + +# Bindings + +# Uncomment the following lines if you want SSL support +# +# + +# Logging + + +# Modules +# + +# +# Include other necessary modules +# For SSL support, include the SSL module +# +# + +# Autojoin Channel Configuration + + +# Channel Configuration + + +# You can set channel-specific settings here +# Example: Set the channel topic +# Welcome to the JoinMarket Pit! + +# Class Configuration + + + + +# Oper Configuration + + + +# Security and Limits + + +# Other configurations as needed + + diff --git a/containers/joinmarket-client-server/Dockerfile b/containers/joinmarket-client-server/Dockerfile new file mode 100644 index 0000000..8c5ee75 --- /dev/null +++ b/containers/joinmarket-client-server/Dockerfile @@ -0,0 +1,26 @@ +FROM joinmarket-test + + +# Copy the config file to the right place + +# Starts the RPC server +# RUN python3 /jm/clientserver/scripts/jmwalletd.py + +RUN useradd -ms /bin/sh joinmarket +USER 1000:1000 + +# Create the SSL directory and generate the SSL certificates +RUN mkdir -p /home/joinmarket/.joinmarket/ssl \ + && openssl req -newkey rsa:2048 -nodes -keyout /home/joinmarket/.joinmarket/ssl/key.pem \ + -x509 -days 365 -out /home/joinmarket/.joinmarket/ssl/cert.pem -subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=localhost" + +# Generates the empty wallet, so we can place the config there +RUN python3 /jm/clientserver/scripts/wallet-tool.py generate; exit 0 + +COPY --chown=joinmarket:joinmarket run.sh /home/joinmarket/ +COPY --chown=joinmarket:joinmarket joinmarket.cfg /home/joinmarket/.joinmarket/ +RUN chmod +x /home/joinmarket/run.sh +WORKDIR /home/joinmarket + +CMD ["./run.sh"] + diff --git a/containers/joinmarket-client-server/joinmarket.cfg b/containers/joinmarket-client-server/joinmarket.cfg new file mode 100644 index 0000000..11aad9d --- /dev/null +++ b/containers/joinmarket-client-server/joinmarket.cfg @@ -0,0 +1,446 @@ +[DAEMON] +# set to 1 to run the daemon service within this process; +# set to 0 if the daemon is run separately (using script joinmarketd.py) +no_daemon = 1 + +# Port on which daemon serves; note that communication still +# occurs over this port even if no_daemon = 1 +daemon_port = 27183 + +# Currently, running the daemon on a remote host is +# *NOT* supported, so don't change this variable +daemon_host = localhost + +# by default the client-daemon connection is plaintext, set to 'true' to use TLS; +# for this, you need to have a valid (self-signed) certificate installed +use_ssl = false + +[BLOCKCHAIN] +# options: bitcoin-rpc, regtest, bitcoin-rpc-no-history, no-blockchain +# When using bitcoin-rpc-no-history remember to increase the gap limit to scan for more addresses, try -g 5000 +# Use 'no-blockchain' to run the ob-watcher.py script in scripts/obwatch without current access +# to Bitcoin Core; note that use of this option for any other purpose is currently unsupported. +# EDITED +blockchain_source = regtest + +# options: signet, testnet, mainnet +# Note: for regtest, use network = testnet +# EDITED +network = testnet + +# EDITED TO THE NAME OF THE CONTAINER. +rpc_host = btc-node +# default ports are 8332 for mainnet, 18443 for regtest, 18332 for testnet, 38332 for signet +rpc_port = 18443 + +# Use either rpc_user / rpc_password pair or rpc_cookie_file. +rpc_user = user +rpc_password = password +#rpc_cookie_file = + +# rpc_wallet_file is Bitcoin Core wallet which is used for address and +# transaction monitoring (it is watchonly, no private keys are stored there). +# It must be created manually if does not exist, see docs/USAGE.md for more +# information. +rpc_wallet_file = jm_wallet + +;[MESSAGING:onion] +# onion based message channels must have the exact type 'onion' +# (while the section name above can be MESSAGING:whatever), and there must +# be only ONE such message channel configured (note the directory servers +# can be multiple, below): +;type = onion +; +;socks5_host = localhost +;socks5_port = 9050 +; +;# the tor control configuration. +;# for most people running the tor daemon +;# on Linux, no changes are required here: +;tor_control_host = localhost +;# or, to use a UNIX socket +;# tor_control_host = unix:/var/run/tor/control +;# note: port needs to be provided (but is ignored for UNIX socket) +;tor_control_port = 9051 +; +;# the host/port actually serving the hidden service +;# (note the *virtual port*, that the client uses, +;# is hardcoded to as per below 'directory node configuration'. +;onion_serving_host = 127.0.0.1 +;onion_serving_port = 8080 +; +;# directory node configuration +;# +;# This is mandatory for directory nodes (who must also set their +;# own *.onion:port as the only directory in directory_nodes, below), +;# but NOT TO BE USED by non-directory nodes (which is you, unless +;# you know otherwise!), as it will greatly degrade your privacy. +;# (note the default is no value, don't replace it with ""). +;hidden_service_dir = +;# +;# This is a comma separated list (comma can be omitted if only one item). +;# Each item has format host:port ; both are required, though port will +;# be 5222 if created in this code. +;# for MAINNET: +;directory_nodes = g3hv4uynnmynqqq2mchf3fcm3yd46kfzmcdogejuckgwknwyq5ya6iad.onion:5222,3kxw6lf5vf6y26emzwgibzhrzhmhqiw6ekrek3nqfjjmhwznb2moonad.onion:5222,bqlpq6ak24mwvuixixitift4yu42nxchlilrcqwk2ugn45tdclg42qid.onion:5222 +; +;# for SIGNET (testing network): +;# directory_nodes = rr6f6qtleiiwic45bby4zwmiwjrj3jsbmcvutwpqxjziaydjydkk5iad.onion:5222,k74oyetjqgcamsyhlym2vgbjtvhcrbxr4iowd4nv4zk5sehw4v665jad.onion:5222,y2ruswmdbsfl4hhwwiqz4m3sx6si5fr6l3pf62d4pms2b53wmagq3eqd.onion:5222 +; +;# This setting is ONLY for developer regtest setups, +;# running multiple bots at once. Don't alter it otherwise +;regtest_count = 0,0 + +## IRC SERVER 1: Darkscience IRC (Tor, IP) +################################################################################ +[MESSAGING:server1] +# by default the legacy format without a `type` field is +# understood to be IRC, but you can, optionally, add it: +# type = irc +channel = joinmarket-pit +port = 6667 +usessl = false + +# For traditional IP: +host = irc-server +socks5 = false +# Needs to present even if not used +socks5_host = placeholder +socks5_port = 0000 + +# For Tor (recommended as clearnet alternative): +;host = darkirc6tqgpnwd3blln3yfv5ckl47eg7llfxkmtovrv7c7iwohhb6ad.onion +;socks5 = true +;socks5_host = localhost +;socks5_port = 9050 + +## IRC SERVER 2: ILITA IRC (optional IRC alternate, Tor only) +################################################################################ +;[MESSAGING:server2] +;channel = joinmarket-pit +;port = 6667 +;usessl = false +;socks5 = true +;socks5_host = localhost +; +;host = ilitafrzzgxymv6umx2ux7kbz3imyeko6cnqkvy4nisjjj4qpqkrptid.onion +;socks5_port = 9050 + +## IRC SERVER 3: (backup) hackint IRC (Tor, IP) +################################################################################ +#[MESSAGING:server3] +# channel = joinmarket-pit +# For traditional IP: +## host = irc.hackint.org +## port = 6697 +## usessl = true +## socks5 = false +# For Tor (default): +#host = ncwkrwxpq2ikcngxq3dy2xctuheniggtqeibvgofixpzvrwpa77tozqd.onion +#port = 6667 +#usessl = false +#socks5 = true +#socks5_host = localhost +#socks5_port = 9050 + +[LOGGING] +# Set the log level for the output to the terminal/console +# Possible choices: DEBUG / INFO / WARNING / ERROR +# Log level for the files in the logs-folder will always be DEBUG +console_log_level = DEBUG + +# Use color-coded log messages to help distinguish log levels?: +color = true + +[TIMEOUT] +maker_timeout_sec = 60 +unconfirm_timeout_sec = 180 +confirm_timeout_hours = 6 + +[POLICY] +# Use segwit style wallets and transactions +# Only set to false for old wallets, Joinmarket is now segwit only. +segwit = true + +# Use native segwit (bech32) wallet. If set to false, p2sh-p2wkh +# will be used when generating the addresses for this wallet. +# Notes: 1. The default joinmarket pit is native segwit. +# 2. You cannot change the type of a pre-existing wallet. +native = true + +# for dust sweeping, try merge_algorithm = gradual +# for more rapid dust sweeping, try merge_algorithm = greedy +# for most rapid dust sweeping, try merge_algorithm = greediest +# but don't forget to bump your miner fees! +merge_algorithm = default + +# Used currently by the RPC to modify the gap limit +# for address searching during wallet sync. Command line +# scripts can use the command line flag `-g` instead. +gaplimit = 6 + +# Disable the caching of addresses and scripts when +# syncing the wallet. You DO NOT need to set this to 'true', +# unless there is an issue of file corruption or a code bug. +wallet_caching_disabled = false + +# The fee estimate is based on a projection of how many sats/kilo-vbyte +# are needed to get in one of the next N blocks. N is set here as +# the value of 'tx_fees'. This cost estimate is high if you set +# N=1, so we choose 3 for a more reasonable figure, as our default. +# You can also set your own fee/kilo-vbyte: any number higher than 1 thousand +# will be interpreted as the fee in sats/kilo-vbyte that you wish to use. +# +# Example: N=30000 will use 30 thousand sats/kilo-vbyte (30 sats/vB) as a fee, +# while N=5 will use the 5 block estimate from your selected blockchain source. +tx_fees = 3 + +# Transaction fee rate variance factor, 0.2 means fee will be random +# between any manually chosen value and 20% above that value, so if you set +# tx_fees=10000 and tx_fees_factor=0.2, it might use any value between +# 10 thousand and 12 thousand for your transactions. +tx_fees_factor = 0.2 + +# For users getting transaction fee estimates over an API, +# place a sanity check limit on the sats/kilo-vbyte to be paid. +# This limit is also applied to users using Core, even though +# Core has its own sanity check limit, which is currently +# 1 million satoshis. +# +# Example: N=350000 will use 350 thousand sats/kilo-vbyte (350 sats/vB) as a +# maximum fee. +absurd_fee_per_kb = 350000 + +# In decimal, the maximum allowable change either lower or +# higher, that the fee rate used for coinjoin sweeps is +# allowed to be. +# (note: coinjoin sweeps *must estimate* fee rates; +# they cannot be exact due to the lack of change output.) +# +# Example: max_sweep_fee_change = 0.4, with tx_fees = 10000, +# means actual fee rate achieved in the sweep can be as low +# as 6 thousand sats/kilo-vbyte up to 14 thousand sats/kilo-vbyte. +# +# If this is not achieved, the transaction is aborted. For tumbler, +# it will then be retried until successful. +# WARNING: too-strict setting may result in using up a lot +# of PoDLE commitments, hence the default 0.8 (80%). +max_sweep_fee_change = 0.8 + +# Maximum absolute coinjoin fee in satoshi to pay to a single +# market maker for a transaction. Both the limits given in +# max_cj_fee_abs and max_cj_fee_rel must be exceeded in order +# to not consider a certain offer. +max_cj_fee_abs = 100000 + +# Maximum relative coinjoin fee, in fractions of the coinjoin value +# e.g. if your coinjoin amount is 2 btc (200 million satoshi) and +# max_cj_fee_rel = 0.001 (0.1%), the maximum fee allowed would +# be 0.002 btc (200 thousand satoshi) +max_cj_fee_rel = 0.01 + +# The range of confirmations passed to the `listunspent` bitcoind RPC call +# 1st value is the inclusive minimum, defaults to one confirmation +# 2nd value is the exclusive maximum, defaults to most-positive-bignum (Google Me!) +# leaving it unset or empty defers to bitcoind's default values, ie [1, 9999999] +#listunspent_args = [] +# That's what you should do, unless you have a specific reason, eg: +# !!! WARNING !!! CONFIGURING THIS WHILE TAKING LIQUIDITY FROM +# !!! WARNING !!! THE PUBLIC ORDERBOOK LEAKS YOUR INPUT MERGES +# spend from unconfirmed transactions: listunspent_args = [0] +# display only unconfirmed transactions: listunspent_args = [0, 1] +# defend against small reorganizations: listunspent_args = [3] +# who is at risk of reorganization?: listunspent_args = [0, 2] +# NB: using 0 for the 1st value with scripts other than wallet-tool could cause +# spends from unconfirmed inputs, which may then get malleated or double-spent! +# other counterparties are likely to reject unconfirmed inputs... don't do it. + +# tx_broadcast: options: self, random-peer, not-self. +# +# self = broadcast transaction with your own bitcoin node. +# +# random-peer = everyone who took part in the coinjoin has a chance of broadcasting +# Note: if your counterparties do not support it, you will fall back +# to broadcasting via your own node. +# +# not-self = never broadcast with your own bitcoin node. +# +# Note: in this case if your counterparties do not broadcast for you, you +# will have to broadcast the tx manually (you can take the tx hex from the log +# or terminal) via some other channel. It is not recommended to choose this +# option when running schedules/tumbler. +tx_broadcast = random-peer + +# If makers do not respond while creating a coinjoin transaction, +# the non-responding ones will be ignored. This is the minimum +# amount of makers which we are content with for the coinjoin to +# succeed. Less makers means that the whole process will restart +# after a timeout. +minimum_makers = 4 + +# Threshold number of satoshis below which an incoming utxo +# to a reused address in the wallet will be AUTOMATICALLY frozen. +# This avoids forced address reuse attacks; see: +# https://en.bitcoin.it/wiki/Privacy#Forced_address_reuse +# +# The default is to ALWAYS freeze a utxo to an already used address, +# whatever the value of it, and this is set with the value -1. +max_sats_freeze_reuse = -1 + +# Interest rate used when calculating the value of fidelity bonds created +# by locking bitcoins in timelocked addresses +# See also: +# https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b#determining-interest-rate-r +# Set as a real number, i.e. 1 = 100% and 0.01 = 1% +interest_rate = 0.015 + +# Some makers run their bots to mix their funds not just to earn money +# So to improve privacy very slightly takers dont always choose a maker based +# on his fidelity bond but allow a certain small percentage to be chosen completely +# randomly without taking into account fidelity bonds +# This parameter sets how many makers on average will be chosen regardless of bonds +# A real number, i.e. 1 = 100%, 0.125 = 1/8 = 1 in every 8 makers on average will be bondless +bondless_makers_allowance = 0.125 + +# To (strongly) disincentivize Sybil behaviour, the value assessment of the bond +# is based on the (time value of the bond)^x where x is the bond_value_exponent here, +# where x > 1. It is a real number (so written as a decimal). +bond_value_exponent = 1.3 + +############################## +# THE FOLLOWING SETTINGS ARE REQUIRED TO DEFEND AGAINST SNOOPERS. +# DON'T ALTER THEM UNLESS YOU UNDERSTAND THE IMPLICATIONS. +############################## + +# Number of retries allowed for a specific utxo, to prevent DOS/snooping. +# Lower settings make snooping more expensive, but also prevent honest users +# from retrying if an error occurs. +taker_utxo_retries = 3 + +# Number of confirmations required for the commitment utxo mentioned above. +# this effectively rate-limits a snooper. +taker_utxo_age = 5 + +# Percentage of coinjoin amount that the commitment utxo must have +# as a minimum BTC amount. Thus 20 means a 1BTC coinjoin requires the +# utxo to be at least 0.2 btc. +taker_utxo_amtpercent = 20 + +# Set to 1 to accept broadcast PoDLE commitments from other bots, and +# add them to your blacklist (only relevant for Makers). +# There is no way to spoof these values, so the only "risk" is that +# someone fills your blacklist file with a lot of data. +accept_commitment_broadcasts = 1 + +# Location of your commitments.json file (stores commitments you've used +# and those you want to use in future), relative to the scripts directory. +commit_file_location = cmtdata/commitments.json + +# Location of the file used by makers to keep track of used/blacklisted +# commitments. For remote daemon, set to `.` to have it stored locally +# (but note that *all* bots using the same code installation share it, +# in this case, which can be bad in testing). +commitment_list_location = cmtdata/commitmentlist + +############################## +# END OF ANTI-SNOOPING SETTINGS +############################## + +[PAYJOIN] +# For the majority of situations, the defaults +# need not be altered - they will ensure you don't pay +# a significantly higher fee. +# MODIFICATION OF THESE SETTINGS IS DISADVISED. + +# Payjoin protocol version; currently only '1' is supported. +payjoin_version = 1 + +# Servers can change their destination address by default (0). +# if '1', they cannot. Note that servers can explicitly request +# that this is activated, in which case we respect that choice. +disable_output_substitution = 0 + +# "default" here indicates that we will allow the receiver to +# increase the fee we pay by: +# 1.2 * (our_fee_rate_per_vbyte * vsize_of_our_input_type) +# (see https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#span_idfeeoutputspanFee_output) +# (and 1.2 to give breathing room) +# which indicates we are allowing roughly one extra input's fee. +# If it is instead set to an integer, then that many satoshis are allowed. +# Additionally, note that we will also set the parameter additionafeeoutputindex +# to that of our change output, unless there is none in which case this is disabled. +max_additional_fee_contribution = default + +# This is the minimum sats/vbyte we allow in the payjoin +# transaction; note it is decimal, not integer. +min_fee_rate = 1.1 + +# For payjoins as sender (i.e. client) to hidden service endpoints, +# the socks5 configuration: +onion_socks5_host = localhost +onion_socks5_port = 9050 + +# For payjoin onion service creation: +# the tor control configuration: +tor_control_host = localhost + +# or, to use a UNIX socket +# control_host = unix:/var/run/tor/control +# note: port needs to be provided (but is ignored for UNIX socket) +tor_control_port = 9051 + +# the host/port actually serving the hidden service +# (note the *virtual port*, that the client uses, +# is hardcoded to 80): +onion_serving_host = 127.0.0.1 +onion_serving_port = 8082 + +# in some exceptional case the HS may be SSL configured, +# this feature is not yet implemented in code, but here for the +# future: +hidden_service_ssl = false + +[YIELDGENERATOR] +# [string, 'reloffer' or 'absoffer'], which fee type to actually use +ordertype = reloffer + +# [satoshis, any integer] / absolute offer fee you wish to receive for coinjoins (cj) +cjfee_a = 500 + +# [fraction, any str between 0-1] / relative offer fee you wish to receive based on a cj's amount +cjfee_r = 0.00002 + +# [fraction, 0-1] / variance around the average fee. Ex: 200 fee, 0.2 var = fee is btw 160-240 +cjfee_factor = 0.1 + +# [satoshis, any integer] / the average transaction fee you're adding to coinjoin transactions +# (note: this will soon be deprecated; leave at zero) +txfee_contribution = 0 + +# [fraction, 0-1] / variance around the average fee. Ex: 1000 fee, 0.2 var = fee is btw 800-1200 +txfee_contribution_factor = 0.3 + +# [satoshis, any integer] / minimum size of your cj offer. Lower cj amounts will be disregarded +minsize = 100000 + +# [fraction, 0-1] / variance around all offer sizes. Ex: 500k minsize, 0.1 var = 450k-550k +size_factor = 0.1 + +[SNICKER] +# Any other value than 'true' will be treated as False, +# and no SNICKER actions will be enabled in that case: +enabled = false + +# In satoshis, we require any SNICKER to pay us at least +# this much (can be negative), otherwise we will refuse +# to sign it: +lowest_net_gain = 0 + +# Comma separated list of servers (if port is omitted as :port, it +# is assumed to be 80) which we will poll against (all, in sequence); note +# that they are allowed to be *.onion or cleartext servers, and no +# scheme (http(s) etc) needs to be added to the start. +servers = cn5lfwvrswicuxn3gjsxoved6l2gu5hdvwy5l3ev7kg6j7lbji2k7hqd.onion, + +# How many minutes between each polling event to each server above: +polling_interval_minutes = 60 diff --git a/containers/joinmarket-client-server/run.sh b/containers/joinmarket-client-server/run.sh new file mode 100644 index 0000000..ff5bfe7 --- /dev/null +++ b/containers/joinmarket-client-server/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Starts the RPC server on 28183 +python3 /jm/clientserver/scripts/jmwalletd.py > /home/joinmarket/jmwalletd.log 2>&1 diff --git a/manager.py b/manager.py index 350c0e5..d6d2511 100644 --- a/manager.py +++ b/manager.py @@ -1,539 +1,38 @@ -from manager.btc_node import BtcNode -from manager.wasabi_backend import WasabiBackend -from manager.wasabi_clients import WasabiClient -from manager import utils +from manager.engine.joinmarket_engine import JoinmarketEngine +from manager.engine.wasabi_engine import WasabiEngine import manager.commands.genscen -from time import sleep, time import sys -import random -import os -import datetime -import json import argparse -import shutil -import tempfile -import multiprocessing -import multiprocessing.pool -import math -DISTRIBUTOR_UTXOS = 20 -BATCH_SIZE = 5 -BTC = 100_000_000 -SCENARIO = { - "name": "default", - "rounds": 10, # the number of coinjoins after which the simulation stops (0 for no limit) - "blocks": 0, # the number of mined blocks after which the simulation stops (0 for no limit) - "default_version": "2.0.4", - "wallets": [ - {"funds": [200000, 50000], "anon_score_target": 7}, - {"funds": [3000000], "redcoin_isolation": True}, - {"funds": [1000000, 500000], "skip_rounds": [0, 1, 2]}, - {"funds": [3000000, 15000]}, - {"funds": [1000000, 500000]}, - {"funds": [3000000, 600000]}, - ], -} args = None -driver = None -node = None -coordinator = None -distributor = None -clients = [] +engine = None versions = set() -invoices = {} - -current_round = 0 -current_block = 0 - - -def prepare_image(name, path=None): - prefixed_name = args.image_prefix + name - if driver.has_image(prefixed_name): - if args.force_rebuild: - if args.image_prefix: - driver.pull(prefixed_name) - print(f"- image pulled {prefixed_name}") - else: - driver.build(name, f"./containers/{name}" if path is None else path) - print(f"- image rebuilt {prefixed_name}") - else: - print(f"- image reused {prefixed_name}") - elif args.image_prefix: - driver.pull(prefixed_name) - print(f"- image pulled {prefixed_name}") - else: - driver.build(name, f"./containers/{name}" if path is None else path) - print(f"- image built {prefixed_name}") - - -def prepare_client_images(): - for version in versions: - major_version = version[0] - name = f"wasabi-client:{version}" - path = f"./containers/wasabi-clients/v{major_version}/{version}" - prepare_image(name, path) - - -def prepare_images(): - print("Preparing images") - prepare_image("btc-node") - prepare_image("wasabi-backend") - prepare_client_images() - - -def start_infrastructure(): - print("Starting infrastructure") - btc_node_ip, btc_node_ports = driver.run( - "btc-node", - f"{args.image_prefix}btc-node", - ports={18443: 18443, 18444: 18444}, - cpu=4.0, - memory=8192, - ) - global node - node = BtcNode( - host=btc_node_ip if args.proxy else args.control_ip, - port=18443 if args.proxy else btc_node_ports[18443], - internal_ip=btc_node_ip, - proxy=args.proxy, - ) - node.wait_ready() - print("- started btc-node") - - wasabi_backend_ip, wasabi_backend_ports = driver.run( - "wasabi-backend", - f"{args.image_prefix}wasabi-backend", - ports={37127: 37127}, - env={ - "WASABI_BIND": "http://0.0.0.0:37127", - "ADDR_BTC_NODE": args.btc_node_ip or node.internal_ip, - }, - cpu=8.0, - memory=8192, - ) - sleep(1) - with open("./containers/wasabi-backend/WabiSabiConfig.json", "r") as config_file: - backend_config = json.load(config_file) - backend_config.update(SCENARIO.get("backend", {})) - - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - scenario_file = tmp_file.name - tmp_file.write(json.dumps(backend_config, indent=2).encode()) - - driver.upload( - "wasabi-backend", - scenario_file, - "/home/wasabi/.walletwasabi/backend/WabiSabiConfig.json", - ) - - global coordinator - coordinator = WasabiBackend( - host=wasabi_backend_ip if args.proxy else args.control_ip, - port=37127 if args.proxy else wasabi_backend_ports[37127], - internal_ip=wasabi_backend_ip, - proxy=args.proxy, - ) - coordinator.wait_ready() - print("- started wasabi-backend") - - distributor_version = SCENARIO.get( - "distributor_version", SCENARIO["default_version"] - ) - wasabi_client_distributor_ip, wasabi_client_distributor_ports = driver.run( - "wasabi-client-distributor", - f"{args.image_prefix}wasabi-client:{distributor_version}", - env={ - "ADDR_BTC_NODE": args.btc_node_ip or node.internal_ip, - "ADDR_WASABI_BACKEND": args.wasabi_backend_ip or coordinator.internal_ip, - }, - ports={37128: 37128}, - cpu=1.0, - memory=2048, - ) - global distributor - distributor = init_wasabi_client( - distributor_version, - wasabi_client_distributor_ip if args.proxy else args.control_ip, - port=37128 if args.proxy else wasabi_client_distributor_ports[37128], - name="wasabi-client-distributor", - delay=(0, 0), - stop=(0, 0), - ) - if not distributor.wait_wallet(timeout=60): - print(f"- could not start distributor (application timeout)") - raise Exception("Could not start distributor") - print("- started distributor") - - -def fund_distributor(btc_amount): - print("Funding distributor") - for _ in range(DISTRIBUTOR_UTXOS): - node.fund_address( - distributor.get_new_address(), - math.ceil(btc_amount * BTC / DISTRIBUTOR_UTXOS) // BTC, - ) - while (balance := distributor.get_balance()) < btc_amount * BTC: - sleep(1) - print(f"- funded (current balance {balance / BTC:.8f} BTC)") - - -def init_wasabi_client(version, ip, port, name, delay, stop): - return WasabiClient(version)( - host=ip, - port=port, - name=name, - proxy=args.proxy, - version=version, - delay=delay, - stop=stop, - ) - - -def start_client(idx, wallet): - version = wallet.get("version", SCENARIO["default_version"]) - - if "anon_score_target" in wallet: - anon_score_target = wallet["anon_score_target"] - else: - anon_score_target = SCENARIO.get("default_anon_score_target", None) - - if anon_score_target is not None and version < "2.0.3": - anon_score_target = None - print( - f"Anon Score Target is ignored for wallet {idx} as it is curently supported only for version 2.0.3 and newer" - ) - - if "redcoin_isolation" in wallet: - redcoin_isolation = wallet["redcoin_isolation"] - else: - redcoin_isolation = SCENARIO.get("default_redcoin_isolation", None) - - if redcoin_isolation is not None and version < "2.0.3": - redcoin_isolation = None - print( - f"Redcoin isolation is ignored for wallet {idx} as it is curently supported only for version 2.0.3 and newer" - ) - - sleep(random.random() * 3) - name = f"wasabi-client-{idx:03}" - try: - ip, manager_ports = driver.run( - name, - f"{args.image_prefix}wasabi-client:{version}", - env={ - "ADDR_BTC_NODE": args.btc_node_ip or node.internal_ip, - "ADDR_WASABI_BACKEND": args.wasabi_backend_ip - or coordinator.internal_ip, - "WASABI_ANON_SCORE_TARGET": ( - str(anon_score_target) if anon_score_target else None - ), - "WASABI_REDCOIN_ISOLATION": ( - str(redcoin_isolation) if redcoin_isolation else None - ), - }, - ports={37128: 37129 + idx}, - cpu=(0.3 if version < "2.0.4" else 0.1), - memory=(1024 if version < "2.0.4" else 768), - ) - except Exception as e: - print(f"- could not start {name} ({e})") - return None - - delay = (wallet.get("delay_blocks", 0), wallet.get("delay_rounds", 0)) - stop = (wallet.get("stop_blocks", 0), wallet.get("stop_rounds", 0)) - client = init_wasabi_client( - version, - ip if args.proxy else args.control_ip, - 37128 if args.proxy else manager_ports[37128], - f"wasabi-client-{idx:03}", - delay, - stop, - ) - - start = time() - if not client.wait_wallet(timeout=60): - print( - f"- could not start {name} (application timeout {time() - start} seconds)" - ) - return None - print(f"- started {client.name} (wait took {time() - start} seconds)") - return client - - -def start_clients(wallets): - print("Starting clients") - with multiprocessing.pool.ThreadPool() as pool: - new_clients = pool.starmap(start_client, enumerate(wallets, start=len(clients))) - - for _ in range(3): - restart_idx = list( - map( - lambda x: x[0], - filter( - lambda x: x[1] is None, - enumerate(new_clients, start=len(clients)), - ), - ) - ) - - if not restart_idx: - break - print(f"- failed to start {len(restart_idx)} clients; retrying ...") - for idx in restart_idx: - driver.stop(f"wasabi-client-{idx:03}") - sleep(60) - restarted_clients = pool.starmap( - start_client, - ((idx, wallets[idx - len(clients)]) for idx in restart_idx), - ) - for idx, client in enumerate(restarted_clients): - if client is not None: - new_clients[restart_idx[idx]] = client - else: - new_clients = list(filter(lambda x: x is not None, new_clients)) - print( - f"- failed to start {len(wallets) - len(new_clients)} clients; continuing ..." - ) - clients.extend(new_clients) - - -def prepare_invoices(wallets): - print("Preparing invoices") - client_invoices = [ - (client, wallet.get("funds", [])) for client, wallet in zip(clients, wallets) - ] - - global invoices - for client, funds in client_invoices: - for fund in funds: - block = 0 - round = 0 - if isinstance(fund, int): - value = fund - elif isinstance(fund, dict): - value = fund.get("value", 0) - block = fund.get("delay_blocks", 0) - round = fund.get("delay_rounds", 0) - addressed_invoice = (client.get_new_address(), value) - if (block, round) not in invoices: - invoices[(block, round)] = [addressed_invoice] - else: - invoices[(block, round)].append(addressed_invoice) - - for addressed_invoices in invoices.values(): - random.shuffle(addressed_invoices) - - print(f"- prepared {sum(map(len, invoices.values()))} invoices") - - -def pay_invoices(addressed_invoices): - print( - f"- paying {len(addressed_invoices)} invoices (batch size {BATCH_SIZE}, block {current_block}, round {current_round})" - ) - try: - for batch in utils.batched(addressed_invoices, BATCH_SIZE): - for _ in range(3): - try: - result = distributor.send(batch) - if str(result) == "timeout": - print("- transaction timeout") - continue - break - except Exception as e: - # https://github.com/zkSNACKs/WalletWasabi/issues/12764 - if "Bad Request" in str(e): - print("- transaction error (bad request)") - else: - print(f"- transaction error ({e})") - else: - print("- invoice payment failed") - raise Exception("Invoice payment failed") - - except Exception as e: - print("- invoice payment failed") - raise e - - -def start_coinjoin(client): - sleep(random.random() / 10) - client.start_coinjoin() - - -def stop_coinjoin(client): - sleep(random.random() / 10) - client.stop_coinjoin() - - -def update_coinjoins(): - def start_condition(client): - if client.stop[0] > 0 and current_block >= client.stop[0]: - return False - if client.stop[1] > 0 and current_round >= client.stop[1]: - return False - if current_block < client.delay[0]: - return False - if current_round < client.delay[1]: - return False - return True - - start, stop = [], [] - for client in clients: - if start_condition(client): - start.append(client) - else: - stop.append(client) - - with multiprocessing.pool.ThreadPool() as pool: - pool.starmap(start_coinjoin, ((client,) for client in start)) - - with multiprocessing.pool.ThreadPool() as pool: - pool.starmap(stop_coinjoin, ((client,) for client in stop)) - - -def update_invoice_payments(): - due = list( - filter( - lambda x: x[0] <= current_block and x[1] <= current_round, invoices.keys() - ) - ) - for i in due: - pay_invoices(invoices.pop(i, [])) - - -def stop_coinjoins(): - print("Stopping coinjoins") - for client in clients: - client.stop_coinjoin() - print(f"- stopped mixing {client.name}") - - -def store_client_logs(client, data_path): - sleep(random.random() * 3) - client_path = os.path.join(data_path, client.name) - os.mkdir(client_path) - with open(os.path.join(client_path, "coins.json"), "w") as f: - json.dump(client.list_coins(), f, indent=2) - print(f"- stored {client.name} coins") - with open(os.path.join(client_path, "unspent_coins.json"), "w") as f: - json.dump(client.list_unspent_coins(), f, indent=2) - print(f"- stored {client.name} unspent coins") - with open(os.path.join(client_path, "keys.json"), "w") as f: - json.dump(client.list_keys(), f, indent=2) - print(f"- stored {client.name} keys") - try: - driver.download(client.name, "/home/wasabi/.walletwasabi/client/", client_path) - - print(f"- stored {client.name} logs") - except: - print(f"- could not store {client.name} logs") - - -def store_logs(): - print("Storing logs") - time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M") - experiment_path = f"./logs/{time}_{SCENARIO['name']}" - data_path = os.path.join(experiment_path, "data") - os.makedirs(data_path) - - with open(os.path.join(experiment_path, "scenario.json"), "w") as f: - json.dump(SCENARIO, f, indent=2) - print("- stored scenario") - - stored_blocks = 0 - node_path = os.path.join(data_path, "btc-node") - os.mkdir(node_path) - while stored_blocks < node.get_block_count(): - block_hash = node.get_block_hash(stored_blocks) - block = node.get_block_info(block_hash) - with open(os.path.join(node_path, f"block_{stored_blocks}.json"), "w") as f: - json.dump(block, f, indent=2) - stored_blocks += 1 - print(f"- stored {stored_blocks} blocks") - - try: - driver.download( - "wasabi-backend", - "/home/wasabi/.walletwasabi/backend/", - os.path.join(data_path, "wasabi-backend"), - ) - - print(f"- stored backend logs") - except: - print(f"- could not store backend logs") - - # TODO parallelize (driver cannot be simply passed to new threads) - for client in clients: - store_client_logs(client, data_path) - - shutil.make_archive(experiment_path, "zip", *os.path.split(experiment_path)) - print("- zip archive created") - def run(): - try: - print(f"=== Scenario {SCENARIO['name']} ===") - prepare_images() - start_infrastructure() - fund_distributor(1000) - start_clients(SCENARIO["wallets"]) - prepare_invoices(SCENARIO["wallets"]) - - print("Running simulation") - global current_round - global current_block - initial_block = node.get_block_count() - while (SCENARIO["rounds"] == 0 or current_round < SCENARIO["rounds"]) and ( - SCENARIO["blocks"] == 0 or current_block < SCENARIO["blocks"] - ): - for _ in range(3): - try: - current_round = sum( - 1 - for _ in driver.peek( - "wasabi-backend", - "/home/wasabi/.walletwasabi/backend/WabiSabi/CoinJoinIdStore.txt", - ).split("\n")[:-1] - ) - break - except Exception as e: - print(f"- could not get rounds".ljust(60), end="\r") - print(f"Round exception: {e}", file=sys.stderr) - - for _ in range(3): - try: - current_block = node.get_block_count() - initial_block - break - except Exception as e: - print(f"- could not get blocks".ljust(60), end="\r") - print(f"Block exception: {e}", file=sys.stderr) - - update_invoice_payments() - update_coinjoins() - print( - f"- coinjoin rounds: {current_round} (block {current_block})".ljust(60), - end="\r", - ) - sleep(1) - print() - print(f"- limit reached") + engine.run() except KeyboardInterrupt: print() print("KeyboardInterrupt received") except Exception as e: print(f"Terminating exception: {e}", file=sys.stderr) finally: - stop_coinjoins() + engine.stop_coinjoins() if not args.no_logs: - store_logs() + engine.store_logs() driver.cleanup(args.image_prefix) - if __name__ == "__main__": parser = argparse.ArgumentParser(description="Run coinjoin simulation setup") subparsers = parser.add_subparsers(dest="command", title="command") + parser.add_argument( + "--engine", + type=str, + choices=["wasabi", "joinmarket"], + default="wasabi", + ) parser.add_argument( "--driver", type=str, @@ -542,6 +41,25 @@ def run(): ) parser.add_argument("--no-logs", action="store_true", default=False) + console_subparser = subparsers.add_parser("console", help="run console") + console_subparser.add_argument( + "--force-rebuild", action="store_true", help="force rebuild of images" + ) + console_subparser.add_argument("--namespace", type=str, default="coinjoin") + console_subparser.add_argument( + "--image-prefix", type=str, default="", help="image prefix" + ) + console_subparser.add_argument("--proxy", type=str, default="") + console_subparser.add_argument( + "--btc-node-ip", type=str, help="override btc-node ip", default="" + ) + console_subparser.add_argument( + "--control-ip", type=str, help="control ip", default="localhost" + ) + console_subparser.add_argument("--reuse-namespace", action="store_true", default=False) + + + build_subparser = subparsers.add_parser("build", help="build images") build_subparser.add_argument( "--force-rebuild", action="store_true", help="force rebuild of images" @@ -600,6 +118,7 @@ def run(): case "docker": from manager.driver.docker import DockerDriver + driver = DockerDriver("coinjoin") driver = DockerDriver(args.namespace) case "podman": from manager.driver.podman import PodmanDriver @@ -613,25 +132,24 @@ def run(): print(f"Unknown driver '{args.driver}'") exit(1) - if args.command == "run": - if args.scenario: - with open(args.scenario) as f: - SCENARIO.update(json.load(f)) + match args.engine: + case "joinmarket": + engine = JoinmarketEngine(args, driver) + case "wasabi": + engine = WasabiEngine(args, driver) + case _: + print(f"Unknown engine '{args.engine}'") + exit(1) - versions.add(SCENARIO["default_version"]) - if "distributor_version" in SCENARIO: - versions.add(SCENARIO["distributor_version"]) - for wallet in SCENARIO["wallets"]: - if "version" in wallet: - versions.add(wallet["version"]) + engine.load_scenario() match args.command: case "build": - prepare_images() + engine.prepare_images() case "clean": driver.cleanup(args.image_prefix) case "run": run() case _: print(f"Unknown command '{args.command}'") - exit(1) + exit(1) \ No newline at end of file diff --git a/manager/btc_node.py b/manager/btc_node.py index 9f1f6a3..9e3d672 100644 --- a/manager/btc_node.py +++ b/manager/btc_node.py @@ -84,3 +84,26 @@ def wait_ready(self): except Exception: pass sleep(0.1) + + def create_wallet(self, wallet): + request = { + "method": "createwallet", + "params": {"wallet_name": "jm_wallet", "descriptors": False}, + } + + request["jsonrpc"] = "2.0" + request["id"] = "1" + try: + response = requests.post( + f"http://{self.host}:{self.port}", + data=json.dumps(request), + auth=("user", "password"), + proxies=dict(http=self.proxy), + timeout=5, + ) + except requests.exceptions.Timeout: + print("timeout") + if response.json()["error"] is not None: + print(response.json()) + raise Exception(response.json()["error"]) + print(response.json()) \ No newline at end of file diff --git a/manager/driver/docker.py b/manager/driver/docker.py index 2d81e5e..eaec88e 100644 --- a/manager/driver/docker.py +++ b/manager/driver/docker.py @@ -93,7 +93,7 @@ def cleanup(self, image_prefix=""): for container in self.client.containers.list(): if any( x in container.attrs["Config"]["Image"] - for x in ("btc-node", "wasabi-backend", "wasabi-client") + for x in ("irc-server", "btc-node", "wasabi-backend", "wasabi-client", "joinmarket-client-server") ): containers.append(container) diff --git a/manager/driver/kubernetes.py b/manager/driver/kubernetes.py index 7b2f35b..695d0f8 100644 --- a/manager/driver/kubernetes.py +++ b/manager/driver/kubernetes.py @@ -234,7 +234,7 @@ def cleanup(self, image_prefix=""): for pod in pods.items: if any( x in pod.metadata.name - for x in ("btc-node", "wasabi-backend", "wasabi-client") + for x in ("irc-server", "btc-node", "wasabi-backend", "wasabi-client", "joinmarket-client-server") ): try: self.client.delete_namespaced_pod( @@ -246,7 +246,7 @@ def cleanup(self, image_prefix=""): for service in services.items: if any( x in service.metadata.name - for x in ("btc-node", "wasabi-backend", "wasabi-client") + for x in ("irc-server", "btc-node", "wasabi-backend", "wasabi-client", "joinmarket-client-server") ): try: self.client.delete_namespaced_service( diff --git a/manager/driver/podman.py b/manager/driver/podman.py index ae90024..b5942dc 100644 --- a/manager/driver/podman.py +++ b/manager/driver/podman.py @@ -90,7 +90,7 @@ def cleanup(self, image_prefix=""): for container in docker.from_env().containers.list(): if any( x in container.attrs["Config"]["Image"] - for x in ("btc-node", "wasabi-backend", "wasabi-client") + for x in ("irc-server", "btc-node", "wasabi-backend", "wasabi-client", "joinmarket-client-server") ): containers.append(container) diff --git a/manager/engine/__init__.py b/manager/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manager/engine/engine_base.py b/manager/engine/engine_base.py new file mode 100644 index 0000000..7803894 --- /dev/null +++ b/manager/engine/engine_base.py @@ -0,0 +1,296 @@ +from manager.btc_node import BtcNode +from manager import utils +from time import sleep +import random +import os +import json +import multiprocessing +import multiprocessing.pool +import math +import shutil +import datetime + +DISTRIBUTOR_UTXOS = 20 +BATCH_SIZE = 5 +BTC = 100_000_000 + + +class EngineBase: + def __init__(self, args, driver, log_src_path): + self.args = args + self.driver = driver + self.log_src_path = log_src_path + self.scenario = self.default_scenario() + self.versions = set() + self.node: BtcNode = None + self.distributor = None + self.clients = [] + self.invoices = {} + self.current_block = 0 + self.current_round = 0 + + def default_scenario(self): + raise NotImplementedError + + def load_scenario(self): + if self.args.command == "run" and self.args.scenario: + with open(self.args.scenario) as f: + self.scenario.update(json.load(f)) + + self.versions.add(self.scenario["default_version"]) + if "distributor_version" in self.scenario: + self.versions.add(self.scenario["distributor_version"]) + for wallet in self.scenario["wallets"]: + if "version" in wallet: + self.versions.add(wallet["version"]) + + + def prepare_images(self): + raise NotImplementedError + + def prepare_image(self, name: str, path=None): + prefixed_name = self.args.image_prefix + name + if self.driver.has_image(prefixed_name): + if self.args.force_rebuild: + if self.args.image_prefix: + self.driver.pull(prefixed_name) + print(f"- image pulled {prefixed_name}") + else: + self.driver.build(name, f"./containers/{name}" if path is None else path) + print(f"- image rebuilt {prefixed_name}") + else: + print(f"- image reused {prefixed_name}") + elif self.args.image_prefix: + self.driver.pull(prefixed_name) + print(f"- image pulled {prefixed_name}") + else: + self.driver.build(name, f"./containers/{name}" if path is None else path) + print(f"- image built {prefixed_name}") + + def start_infrastructure(self): + print("Starting infrastructure") + self.start_btc_node() + self.start_engine_infrastructure() + self.start_distributor() + + def start_btc_node(self): + btc_node_ip, btc_node_ports = self.driver.run( + "btc-node", + f"{self.args.image_prefix}btc-node", + ports={18443: 18443, 18444: 18444}, + cpu=4.0, + memory=8192, + ) + + self.node = BtcNode( + host=btc_node_ip if self.args.proxy else self.args.control_ip, + port=18443 if self.args.proxy else btc_node_ports[18443], + internal_ip=btc_node_ip, + proxy=self.args.proxy, + ) + self.node.wait_ready() + print("- started btc-node") + + def start_engine_infrastructure(self): + raise NotImplementedError + + def start_distributor(self): + raise NotImplementedError + + def init_client(self): + raise NotImplementedError + + def start_client(self, idx: int, wallet=None): + raise NotImplementedError + + def stop_client(self, idx: int): + raise NotImplementedError + + def start_clients(self, wallets): + print("Starting clients") + with multiprocessing.pool.ThreadPool() as pool: + new_clients = pool.starmap(self.start_client, enumerate(wallets, start=len(self.clients))) + + for _ in range(3): + restart_idx = list( + map( + lambda x: x[0], + filter( + lambda x: x[1] is None, + enumerate(new_clients, start=len(self.clients)), + ), + ) + ) + + if not restart_idx: + break + print(f"- failed to start {len(restart_idx)} clients; retrying ...") + for idx in restart_idx: + self.stop_client(idx) + sleep(60) + restarted_clients = pool.starmap( + self.start_client, + ((idx, wallets[idx - len(self.clients)]) for idx in restart_idx), + ) + for idx, client in enumerate(restarted_clients): + if client is not None: + new_clients[restart_idx[idx]] = client + else: + new_clients = list(filter(lambda x: x is not None, new_clients)) + print( + f"- failed to start {len(wallets) - len(new_clients)} clients; continuing ..." + ) + self.clients.extend(new_clients) + + + def fund_distributor(self, btc_amount): + print("Funding distributor") + for _ in range(DISTRIBUTOR_UTXOS): + self.node.fund_address( + self.distributor.get_new_address(), + math.ceil(btc_amount * BTC / DISTRIBUTOR_UTXOS) // BTC, + ) + + while (balance := self.distributor.get_balance()) < btc_amount * BTC: + sleep(1) + print(f"- funded (current balance {balance / BTC:.8f} BTC)") + + + def store_client_logs(self, client, data_path): + sleep(random.random() * 3) + client_path = os.path.join(data_path, client.name) + os.mkdir(client_path) + with open(os.path.join(client_path, "coins.json"), "w") as f: + json.dump(client.list_coins(), f, indent=2) + print(f"- stored {client.name} coins") + with open(os.path.join(client_path, "unspent_coins.json"), "w") as f: + json.dump(client.list_unspent_coins(), f, indent=2) + print(f"- stored {client.name} unspent coins") + with open(os.path.join(client_path, "keys.json"), "w") as f: + json.dump(client.list_keys(), f, indent=2) + print(f"- stored {client.name} keys") + try: + self.driver.download(client.name, self.log_src_path, client_path) + + print(f"- stored {client.name} logs") + except: + print(f"- could not store {client.name} logs") + + def store_logs(self): + print("Storing logs") + time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M") + experiment_path = f"./logs/{time}_{self.scenario['name']}" + data_path = os.path.join(experiment_path, "data") + os.makedirs(data_path) + + with open(os.path.join(experiment_path, "scenario.json"), "w") as f: + json.dump(self.scenario, f, indent=2) + print("- stored scenario") + + stored_blocks = 0 + node_path = os.path.join(data_path, "btc-node") + os.mkdir(node_path) + while stored_blocks < self.node.get_block_count(): + block_hash = self.node.get_block_hash(stored_blocks) + block = self.node.get_block_info(block_hash) + with open(os.path.join(node_path, f"block_{stored_blocks}.json"), "w") as f: + json.dump(block, f, indent=2) + stored_blocks += 1 + print(f"- stored {stored_blocks} blocks") + + self.store_engine_logs(data_path) + + + # TODO parallelize (driver cannot be simply passed to new threads) + for client in self.clients: + self.store_client_logs(client, data_path) + + shutil.make_archive(experiment_path, "zip", *os.path.split(experiment_path)) + print("- zip archive created") + + def store_engine_logs(self, data_path): + raise NotImplementedError + + def stop_coinjoins(self): + print("Stopping coinjoins") + for client in self.clients: + client.stop_coinjoin() + print(f"- stopped mixing {client.name}") + + def update_invoice_payments(self): + due = list( + filter( + lambda x: x[0] <= self.current_block and x[1] <= self.current_round, self.invoices.keys() + ) + ) + for i in due: + self.pay_invoices(self.invoices.pop(i, [])) + + def prepare_invoices(self, wallets): + print("Preparing invoices") + client_invoices = [ + (client, wallet.get("funds", [])) for client, wallet in zip(self.clients, wallets) + ] + + for client, funds in client_invoices: + for fund in funds: + block = 0 + round = 0 + if isinstance(fund, int): + value = fund + elif isinstance(fund, dict): + value = fund.get("value", 0) + block = fund.get("delay_blocks", 0) + round = fund.get("delay_rounds", 0) + addressed_invoice = (client.get_new_address(), value) + if (block, round) not in self.invoices: + self.invoices[(block, round)] = [addressed_invoice] + else: + self.invoices[(block, round)].append(addressed_invoice) + + for addressed_invoices in self.invoices.values(): + random.shuffle(addressed_invoices) + + print(f"- prepared {sum(map(len, self.invoices.values()))} invoices") + + + def pay_invoices(self, addressed_invoices): + print( + f"- paying {len(addressed_invoices)} invoices (batch size {BATCH_SIZE}, block {self.current_block}, round {self.current_round})" + ) + try: + for batch in utils.batched(addressed_invoices, BATCH_SIZE): + for _ in range(3): + try: + result = self.distributor.send(batch) + if str(result) == "timeout": + print("- transaction timeout") + continue + break + except Exception as e: + # https://github.com/zkSNACKs/WalletWasabi/issues/12764 + if "Bad Request" in str(e): + print("- transaction error (bad request)") + else: + print(f"- transaction error ({e})") + else: + print("- invoice payment failed") + raise Exception("Invoice payment failed") + + except Exception as e: + print("- invoice payment failed") + raise e + + def run(self): + print(f"=== Scenario {self.scenario['name']} ===") + self.prepare_images() + self.start_infrastructure() + self.fund_distributor(1000) + self.start_clients(self.scenario["wallets"]) + self.prepare_invoices(self.scenario["wallets"]) + print("Running simulation") + self.run_engine() + + + def run_engine(self): + raise NotImplementedError \ No newline at end of file diff --git a/manager/engine/joinmarket_engine.py b/manager/engine/joinmarket_engine.py new file mode 100644 index 0000000..bd93b25 --- /dev/null +++ b/manager/engine/joinmarket_engine.py @@ -0,0 +1,191 @@ +from manager.engine.engine_base import EngineBase +from manager.wasabi_clients.joinmarket_client import JoinMarketClientServer +from time import sleep, time +import sys + +SCENARIO = { + "name": "default", + "default_version": "joinmarket", + "rounds": 5, # the number of coinjoins after which the simulation stops (0 for no limit) + "blocks": 0, # the number of mined blocks after which the simulation stops (0 for no limit) + "wallets": [ + {"funds": [200000, 50000], "type": "taker"}, + {"funds": [3000000], "type": "taker", "delay_blocks": 2}, + {"funds": [1000000, 500000], "type": "maker"}, + {"funds": [3000000, 15000], "type": "maker"}, + {"funds": [1000000, 500000], "type": "maker"}, + {"funds": [3000000, 600000], "type": "maker"}, + {"funds": [200000, 50000], "type": "maker"}, + {"funds": [3000000], "type": "maker"}, + {"funds": [1000000, 500000], "type": "maker"}, + {"funds": [3000000, 15000], "type": "maker"}, + {"funds": [1000000, 500000], "type": "maker"}, + {"funds": [3000000, 600000], "type": "maker"}, + ], +} + + +class JoinmarketEngine(EngineBase): + + def __init__(self, args, driver): + super().__init__(args, driver, "/home/joinmarket") + + def default_scenario(self): + return SCENARIO + + def prepare_images(self): + print("Preparing images") + self.prepare_image("btc-node") + self.prepare_image("joinmarket-client-server") + self.prepare_image("irc-server") + + + def start_engine_infrastructure(self): + self.node.create_wallet("jm_wallet") + print("- created jm_wallet in BitcoinCore") + + self.start_irc_server() + print("- started irc-server") + + + def start_irc_server(self): + name = "irc-server" + + try: + ip, manager_ports = self.driver.run( + name, + f"{self.args.image_prefix}irc-server", + env={}, # Add any necessary environment variables + ports={6667: 6667}, + cpu=1.0, + memory=2048, + ) + except Exception as e: + print(f"- could not start {name} ({e})") + raise Exception("Could not start IRC server") + + + def start_distributor(self): + name = "joinmarket-distributor" + port = 28183 # Use a specific port for the distributor + try: + ip, manager_ports = self.driver.run( + name, + "joinmarket-client-server:latest", + env={}, # Add any necessary environment variables + ports={28183: port}, + cpu=1.0, + memory=2048, + ) + except Exception as e: + print(f"- could not start {name} ({e})") + raise Exception("Could not start distributor") + + self.distributor = self.init_joinmarket_clientserver(name=name, port=port) + + start = time() + if not self.distributor.wait_wallet(timeout=60): + print(f"- could not start {name} (application timeout)") + raise Exception("Could not start distributor") + print(f"- started distributor") + + + def init_joinmarket_clientserver(self, name, port, host="localhost", type="maker"): + return JoinMarketClientServer(name=name, port=port, type=type) + + + def start_client(self, idx: int, wallet=None): + name = f"jcs-{idx:03}" + port = 28184 + idx + try: + ip, manager_ports = self.driver.run( + name, + "joinmarket-client-server:latest", + env={}, + ports={28183: port}, + cpu=(0.1), + memory=(768), + ) + except Exception as e: + print(f"- could not start {name} ({e})") + return None + + print(f"driver starting {name}") + + delay = (wallet.get("delay_blocks", 0), wallet.get("delay_rounds", 0)) + stop = (wallet.get("stop_blocks", 0), wallet.get("stop_rounds", 0)) + type = wallet.get("type", "maker") + + client = JoinMarketClientServer(name=name, port=port, type=type, delay=delay, stop=stop) + + + start = time() + if not client.wait_wallet(timeout=60): + print( + f"- could not start {name} (application timeout {time() - start} seconds)" + ) + return None + + print(f"- started {client.name} (wait took {time() - start} seconds)") + return client + + def stop_client(self, idx: int): + name = f"jcs-{idx:03}" + self.driver.stop(name) + + def store_engine_logs(self, data_path): + # TODO: store irc logs. + pass + + def update_coinjoins_joinmarket(self): + for client in self.clients: + state = client.get_status() + # print(state) + if client.type == "maker" and not client.maker_running and not client.delay[0] > self.current_block: + client.start_maker(0, 5000, 0.00004, "sw0reloffer", 30000) + print(f"Starting maker {client.name}") + + if client.type == "taker" and not client.coinjoin_in_process and not client.delay[0] > self.current_block: + self.current_round += 1 + address = client.get_new_address() + client.start_coinjoin(0, 40000, 4, address) + client.coinjoin_start = self.current_block + print(f"Starting coinjoin {client.name}") + + if client.type == "taker" and client.coinjoin_in_process and client.coinjoin_start + 4 < self.current_block: + self.current_round -= 1 + client.stop_coinjoin() + client.coinjoin_in_process = False + print(f"Stopping coinjoin {client.name}") + + + def run_engine(self): + self.update_invoice_payments() + initial_block = self.node.get_block_count() + for i in range(5): + # Takers need 3 confirmations of transactions for the sourcing commitments + self.node.mine_block() + + while ( self.scenario["rounds"] == 0 or self.current_round < self.scenario["rounds"] ) and ( + self.scenario["blocks"] == 0 or self.current_block < self.scenario["blocks"]): + for _ in range(3): + try: + self.current_block = self.node.get_block_count() - initial_block + break + except Exception as e: + print(f"- could not get blocks".ljust(60), end="\r") + print(f"Block exception: {e}", file=sys.stderr) + + self.update_invoice_payments() + self.update_coinjoins_joinmarket() + + print( + f"- coinjoin rounds: {self.current_round} (block {self.current_block})".ljust(60), + end="\r", + ) + sleep(1) + + print() + print(f"- limit reached") + sleep(60) + self.node.mine_block() diff --git a/manager/engine/wasabi_engine.py b/manager/engine/wasabi_engine.py new file mode 100644 index 0000000..757c7a8 --- /dev/null +++ b/manager/engine/wasabi_engine.py @@ -0,0 +1,285 @@ +import os + +from manager.engine.engine_base import EngineBase +from manager.wasabi_backend import WasabiBackend +from manager.wasabi_clients import WasabiClient +from time import sleep, time +import sys +import random +import json +import tempfile +import multiprocessing +import multiprocessing.pool + +SCENARIO = { + "name": "default", + "rounds": 10, # the number of coinjoins after which the simulation stops (0 for no limit) + "blocks": 0, # the number of mined blocks after which the simulation stops (0 for no limit) + "default_version": "2.0.4", + "wallets": [ + {"funds": [200000, 50000], "anon_score_target": 7}, + {"funds": [3000000], "redcoin_isolation": True}, + {"funds": [1000000, 500000], "skip_rounds": [0, 1, 2]}, + {"funds": [3000000, 15000]}, + {"funds": [1000000, 500000]}, + {"funds": [3000000, 600000]}, + ], +} + +class WasabiEngine(EngineBase): + def __init__(self, args, driver): + self.coordinator = None + super().__init__(args, driver, "/home/wasabi/.walletwasabi/backend/") + + def default_scenario(self): + return SCENARIO + + def prepare_images(self): + print("Preparing images") + self.prepare_image("btc-node") + self.prepare_client_images() + + def prepare_client_images(self): + for version in self.versions: + major_version = version[0] + name = f"wasabi-client:{version}" + path = f"./containers/wasabi-clients/v{major_version}/{version}" + self.prepare_image(name, path) + + + def start_engine_infrastructure(self): + self.start_wasabi_backend() + + + def start_wasabi_backend(self): + wasabi_backend_ip, wasabi_backend_ports = self.driver.run( + "wasabi-backend", + f"{self.args.image_prefix}wasabi-backend", + ports={37127: 37127}, + env={ + "WASABI_BIND": "http://0.0.0.0:37127", + "ADDR_BTC_NODE": self.args.btc_node_ip or self.node.internal_ip, + }, + cpu=8.0, + memory=8192, + ) + sleep(1) + with open("./containers/wasabi-backend/WabiSabiConfig.json", "r") as config_file: + backend_config = json.load(config_file) + backend_config.update(SCENARIO.get("backend", {})) + + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + scenario_file = tmp_file.name + tmp_file.write(json.dumps(backend_config, indent=2).encode()) + + self.driver.upload( + "wasabi-backend", + scenario_file, + "/home/wasabi/.walletwasabi/backend/WabiSabiConfig.json", + ) + + self.coordinator = WasabiBackend( + host=wasabi_backend_ip if self.args.proxy else self.args.control_ip, + port=37127 if self.args.proxy else wasabi_backend_ports[37127], + internal_ip=wasabi_backend_ip, + proxy=self.args.proxy, + ) + self.coordinator.wait_ready() + print("- started wasabi-backend") + + + def start_distributor(self): + distributor_version = self.scenario.get( + "distributor_version", self.scenario["default_version"] + ) + wasabi_client_distributor_ip, wasabi_client_distributor_ports = self.driver.run( + "wasabi-client-distributor", + f"{self.args.image_prefix}wasabi-client:{distributor_version}", + env={ + "ADDR_BTC_NODE": self.args.btc_node_ip or self.node.internal_ip, + "ADDR_WASABI_BACKEND": self.args.wasabi_backend_ip or self.coordinator.internal_ip, + }, + ports={37128: 37128}, + cpu=1.0, + memory=2048, + ) + + self.distributor = self.init_wasabi_client( + distributor_version, + wasabi_client_distributor_ip if self.args.proxy else self.args.control_ip, + port=37128 if self.args.proxy else wasabi_client_distributor_ports[37128], + name="wasabi-client-distributor", + delay=(0, 0), + stop=(0, 0), + ) + if not self.distributor.wait_wallet(timeout=60): + print(f"- could not start distributor (application timeout)") + raise Exception("Could not start distributor") + print("- started distributor") + + def init_wasabi_client(self, version, ip, port, name, delay, stop): + return WasabiClient(version)( + host=ip, + port=port, + name=name, + proxy=self.args.proxy, + version=version, + delay=delay, + stop=stop, + ) + + def start_client(self, idx, wallet): + version = wallet.get("version", self.scenario["default_version"]) + + if "anon_score_target" in wallet: + anon_score_target = wallet["anon_score_target"] + else: + anon_score_target = self.scenario.get("default_anon_score_target", None) + + if anon_score_target is not None and version < "2.0.3": + anon_score_target = None + print( + f"Anon Score Target is ignored for wallet {idx} as it is curently supported only for version 2.0.3 and newer" + ) + + if "redcoin_isolation" in wallet: + redcoin_isolation = wallet["redcoin_isolation"] + else: + redcoin_isolation = self.scenario.get("default_redcoin_isolation", None) + + if redcoin_isolation is not None and version < "2.0.3": + redcoin_isolation = None + print( + f"Redcoin isolation is ignored for wallet {idx} as it is curently supported only for version 2.0.3 and newer" + ) + + sleep(random.random() * 3) + name = f"wasabi-client-{idx:03}" + try: + ip, manager_ports = self.driver.run( + name, + f"{self.args.image_prefix}wasabi-client:{version}", + env={ + "ADDR_BTC_NODE": self.args.btc_node_ip or self.node.internal_ip, + "ADDR_WASABI_BACKEND": self.args.wasabi_backend_ip + or self.coordinator.internal_ip, + "WASABI_ANON_SCORE_TARGET": ( + str(anon_score_target) if anon_score_target else None + ), + "WASABI_REDCOIN_ISOLATION": ( + str(redcoin_isolation) if redcoin_isolation else None + ), + }, + ports={37128: 37129 + idx}, + cpu=(0.3 if version < "2.0.4" else 0.1), + memory=(1024 if version < "2.0.4" else 768), + ) + except Exception as e: + print(f"- could not start {name} ({e})") + return None + + delay = (wallet.get("delay_blocks", 0), wallet.get("delay_rounds", 0)) + stop = (wallet.get("stop_blocks", 0), wallet.get("stop_rounds", 0)) + client = self.init_wasabi_client( + version, + ip if self.args.proxy else self.args.control_ip, + 37128 if self.args.proxy else manager_ports[37128], + f"wasabi-client-{idx:03}", + delay, + stop, + ) + + start = time() + if not client.wait_wallet(timeout=60): + print( + f"- could not start {name} (application timeout {time() - start} seconds)" + ) + return None + print(f"- started {client.name} (wait took {time() - start} seconds)") + return client + + def stop_client(self, idx: int): + self.driver.stop(f"wasabi-client-{idx:03}") + + def store_engine_logs(self, data_path): + try: + self.driver.download( + "wasabi-backend", + "/home/wasabi/.walletwasabi/backend/", + os.path.join(data_path, "wasabi-backend"), + ) + + print(f"- stored backend logs") + except: + print(f"- could not store backend logs") + + def start_coinjoin(self, client): + sleep(random.random() / 10) + client.start_coinjoin() + + def stop_coinjoin(self, client): + sleep(random.random() / 10) + client.stop_coinjoin() + + def update_coinjoins(self): + def start_condition(client): + if client.stop[0] > 0 and self.current_block >= client.stop[0]: + return False + if client.stop[1] > 0 and self.current_round >= client.stop[1]: + return False + if self.current_block < client.delay[0]: + return False + if self.current_round < client.delay[1]: + return False + return True + + start, stop = [], [] + for client in self.clients: + if start_condition(client): + start.append(client) + else: + stop.append(client) + + with multiprocessing.pool.ThreadPool() as pool: + pool.starmap(self.start_coinjoin, ((client,) for client in start)) + + with multiprocessing.pool.ThreadPool() as pool: + pool.starmap(self.stop_coinjoin, ((client,) for client in stop)) + + def run_engine(self): + print("Running simulation") + initial_block = self.node.get_block_count() + while (self.scenario["rounds"] == 0 or self.current_round < self.scenario["rounds"]) and ( + self.scenario["blocks"] == 0 or self.current_block < self.scenario["blocks"] + ): + for _ in range(3): + try: + self.current_round = sum( + 1 + for _ in self.driver.peek( + "wasabi-backend", + "/home/wasabi/.walletwasabi/backend/WabiSabi/CoinJoinIdStore.txt", + ).split("\n")[:-1] + ) + break + except Exception as e: + print(f"- could not get rounds".ljust(60), end="\r") + print(f"Round exception: {e}", file=sys.stderr) + + for _ in range(3): + try: + self.current_block = self.node.get_block_count() - initial_block + break + except Exception as e: + print(f"- could not get blocks".ljust(60), end="\r") + print(f"Block exception: {e}", file=sys.stderr) + + self.update_invoice_payments() + self.update_coinjoins() + print( + f"- coinjoin rounds: {self.current_round} (block {self.current_block})".ljust(60), + end="\r", + ) + sleep(1) + print() + print(f"- limit reached") diff --git a/manager/wasabi_clients/joinmarket_client.py b/manager/wasabi_clients/joinmarket_client.py new file mode 100644 index 0000000..83b5445 --- /dev/null +++ b/manager/wasabi_clients/joinmarket_client.py @@ -0,0 +1,375 @@ +import json + +import requests +from time import sleep, time +from urllib3.exceptions import InsecureRequestWarning +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +WALLET_NAME = "wallet" +PASSWORD = "password" +WALLET_TYPE = "sw" +BTC = 100_000_000 + + +class JoinmarketConflictException(Exception): + def __init__(self, message, response): + super().__init__(message) + self.response = response + + + +class JoinMarketClientServer: + def __init__( + self, + host="localhost", + port=28183, + walletname=WALLET_NAME, + name="joinmarket-client-server", + proxy="", + version="", + type="maker", + delay=(0, 0), + stop=(0, 0), + ): + self.host = host + self.port = port + self.walletname = walletname # Store walletname as an instance variable + self.name = name + self.proxy = proxy + self.version = version + self.type = type + self.maker_running = False + self.coinjoin_in_process = False + self.coinjoin_start = 0 + self.delay = delay + self.stop = stop + self.token = "" + self.refresh_token = "" + + def _rpc(self, method, endpoint, json_data=None, timeout=5, repeat=4) -> dict: + headers = {} + if self.token: + headers['Authorization'] = f'Bearer {self.token}' + response = None + for _ in range(repeat): + try: + response = requests.request( + method=method, + url=f"https://{self.host}:{self.port}/api/v1{endpoint}", + json=json_data or {}, + headers=headers, + proxies=dict(http=self.proxy), + timeout=timeout, + verify=False, + ) + except requests.exceptions.Timeout: + continue + except InsecureRequestWarning: + continue + + if response.status_code == 401: + self.unlock_wallet() + headers['Authorization'] = f'Bearer {self.token}' + continue + + if response.status_code == 409: + raise JoinmarketConflictException(f"Error {response.status_code}: {response.text}", response) + + if response.status_code >= 400: + try: + print(response.json()) + error_message = response.json().get("message", "Unknown error") + except json.JSONDecodeError: + error_message = response.text + raise Exception(f"Error {response.status_code}: {error_message}") + + return response.json() + + if response is not None: + return response.json() + + raise Exception("timeout") + + def get_status(self): + method = "GET" + endpoint = "/session" + response = self._rpc(method, endpoint) + self.maker_running = response.get("maker_running", False) + self.coinjoin_in_process = response.get("coinjoin_in_process", False) + return response + + def _create_wallet(self, walletname=None): + """Create a new wallet and store its name.""" + method = "POST" + endpoint = "/wallet/create" + self.walletname = walletname or self.walletname or WALLET_NAME + data = { + "walletname": self.walletname, + "password": PASSWORD, + "wallettype": WALLET_TYPE + } + response = self._rpc(method, endpoint, json_data=data) + self.token = response.get("token", "") + self.refresh_token = response.get("refresh_token", "") + return response + + def unlock_wallet(self, password=None): + """Unlock an existing wallet using the stored walletname.""" + method = "POST" + endpoint = f"/wallet/{self.walletname}/unlock" + json_data = {"password": password or PASSWORD} + response = self._rpc(method, endpoint, json_data=json_data) + self.token = response.get("token", "") + self.refresh_token = response.get("refresh_token", "") + return response + + + def wait_wallet(self, timeout=None): + start = time() + while timeout is None or time() - start < timeout: + try: + self._create_wallet() + except Exception as e: + pass + + try: + self.get_balance() + return True + except Exception as e: + pass + + sleep(0.1) + return False + + + def display_wallet(self): + """Get detailed breakdown of wallet contents by account.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/display" + response = self._rpc(method, endpoint) + return response + + def get_balance(self): + """Retrieve the available balance of the wallet. + Returns: str: The available balance as a string in BTC (e.g., '0.00000000'). + Raises: Exception: If the balance information cannot be retrieved. + """ + response = self.display_wallet() + try: + available_balance = response['walletinfo']['available_balance'] + return int(float(available_balance) * BTC) + except KeyError as e: + raise Exception(f"Could not retrieve available balance: {e}") + + def get_yieldgen_report(self): + """Get the latest report on yield-generating activity.""" + method = "GET" + endpoint = "/wallet/yieldgen/report" + response = self._rpc(method, endpoint) + return response + + def get_new_address(self, mixdepth=0): + """Get a fresh address in the given account for depositing funds.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/address/new/{mixdepth}" + response = self._rpc(method, endpoint) + return response['address'] + + def get_new_timelock_address(self, lockdate): + """Get a fresh timelock address for depositing funds to create a fidelity bond.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/address/timelock/new/{lockdate}" + response = self._rpc(method, endpoint) + return response + + def list_utxos(self): + """List details of all UTXOs currently in the wallet.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/utxos" + response = self._rpc(method, endpoint) + return response + + def start_maker( + self, + txfee, + cjfee_a, + cjfee_r, + ordertype, + minsize, + ): + """ + Start the yield generator service with the specified configuration. + - txfee: str or int, e.g., "0" (absolute fee in satoshis) + - cjfee_a: str or int, e.g., "5000" (absolute coinjoin fee in satoshis) + - cjfee_r: str or float, e.g., "0.00004" (relative coinjoin fee as a fraction) + - ordertype: str, e.g., "reloffer" or "absoffer" + - minsize: str or int, minimum coinjoin size in satoshis. Should be higher then 27300sats + """ + method = "POST" + endpoint = f"/wallet/{self.walletname}/maker/start" + json_data = { + "txfee": str(txfee), + "cjfee_a": str(cjfee_a), + "cjfee_r": str(cjfee_r), + "ordertype": ordertype, + "minsize": str(minsize) + } + + try: + response = self._rpc(method, endpoint, json_data=json_data) + except JoinmarketConflictException as e: + print("Could not start maker without confirmed balance") + response = e.response + + return response + + def stop_maker(self): + """Stop the yield generator service.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/maker/stop" + # When stopping not running maker, returns 401 response + response = self._rpc(method, endpoint) + return response + + def start_coinjoin( + self, + mixdepth, + amount_sats, + counterparties, + destination, + txfee=None + ): + """ + Initiate a coinjoin as taker. + - mixdepth: int, the mixdepth to spend from + - amount_sats: int, amount in satoshis to coinjoin + - counterparties: int, number of counterparties to coinjoin with + - destination: str, address to send the coinjoined funds to + - txfee: optional, int, Bitcoin miner fee to use for transaction + """ + method = "POST" + endpoint = f"/wallet/{self.walletname}/taker/coinjoin" + json_data = { + "mixdepth": mixdepth, + "amount_sats": amount_sats, + "counterparties": counterparties, + "destination": destination + } + if txfee is not None: + json_data["txfee"] = txfee + response = self._rpc(method, endpoint, json_data=json_data) + return response + + def run_schedule(self, destination_addresses, tumbler_options=None): + """ + Create and run a schedule of transactions. + - destination_addresses: list of str, addresses to send funds to + - tumbler_options: optional, dict, additional tumbler configuration options + """ + method = "POST" + endpoint = f"/wallet/{self.walletname}/taker/schedule" + json_data = { + "destination_addresses": destination_addresses, + } + if tumbler_options: + json_data["tumbler_options"] = tumbler_options + response = self._rpc(method, endpoint, json_data=json_data) + return response + + def get_schedule(self): + """Get the schedule that is currently running.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/taker/schedule" + response = self._rpc(method, endpoint) + return response + + def stop_coinjoin(self): + """Stop a running coinjoin attempt.""" + if self.type == "taker" and self.coinjoin_in_process: + return self.stop_taker() + elif self.type == "maker" and self.maker_running: + return self.stop_maker() + else: + print("No coinjoin in process") + return True + + def stop_taker(self): + method = "GET" + endpoint = f"/wallet/{self.walletname}/taker/stop" + # When stopping not running taker, returns 401 response + response = self._rpc(method, endpoint) + return response + + def send(self, addressed_fundings): + try: + for address, amount in addressed_fundings: + self.simple_send(destination_address=address, amount_sats=amount) + print(f"- sent {amount} sats to {address}") + sleep(5) # The btc node needs time to process the transaction + except Exception as e: + print(f"- error during fund distribution: {e}") + raise e + + + def simple_send(self, destination_address, amount_sats, mixdepth=0, txfee=5000): + """ + Send funds to a single address without coinjoin. + - destination_address: str, address to send funds to + - amount_sats: int, amount in satoshis to send + - mixdepth: int, the mixdepth to spend from + - txfee: int, miner fee in satoshis + """ + method = "POST" + endpoint = f"/wallet/{self.walletname}/taker/direct-send" + json_data = { + "destination": destination_address, + "amount_sats": amount_sats, + "txfee": txfee, + "mixdepth": mixdepth, + } + start = time() + while time() - start < 30: + try: + response = self._rpc(method, endpoint, json_data=json_data) + return response + except Exception as e: + print(e) + sleep(2) + + print("Failed to send funds, attempt timed out.") + + return False + + def list_unspent_coins(self): + """List all unspent coins in the wallet.""" + method = "GET" + endpoint = f"/wallet/{self.walletname}/utxos" + response = self._rpc(method, endpoint) + return response + + def list_transactions_maker(self): + """List all transactions in the wallet.""" + method = "GET" + endpoint = f"/wallet/yieldgen/report" + response = self._rpc(method, endpoint) + return response + + + def list_coins(self): + """List all coins in the wallet.""" + return "This method is not available in joinmarket" + # method = "GET" + # endpoint = f"/wallet/{self.walletname}/coins" + # response = self._rpc(method, endpoint) + # return response + + def list_keys(self): + """List all keys in the wallet.""" + return "This method is not available in joinmarket" + # method = "GET" + # endpoint = f"/wallet/{self.walletname}/keys" + # response = self._rpc(method, endpoint) + # return response \ No newline at end of file