-
Notifications
You must be signed in to change notification settings - Fork 179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add RPC API endpoint for tumbler #1252
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,6 +10,7 @@ | |
from autobahn.twisted.websocket import listenWS | ||
from klein import Klein | ||
import jwt | ||
import pprint | ||
|
||
from jmbitcoin import human_readable_transaction | ||
from jmclient import Taker, jm_single, \ | ||
|
@@ -20,7 +21,11 @@ | |
create_wallet, get_max_cj_fee_values, \ | ||
StorageError, StoragePasswordError, JmwalletdWebSocketServerFactory, \ | ||
JmwalletdWebSocketServerProtocol, RetryableStorageError, \ | ||
SegwitWalletFidelityBonds, wallet_gettimelockaddress, NotEnoughFundsException | ||
SegwitWalletFidelityBonds, wallet_gettimelockaddress, \ | ||
NotEnoughFundsException, get_tumble_log, get_tumble_schedule, \ | ||
get_schedule, get_tumbler_parser, schedule_to_text, \ | ||
tumbler_filter_orders_callback, tumbler_taker_finished_update, \ | ||
validate_address | ||
from jmbase.support import get_log, utxostr_to_utxo | ||
|
||
jlog = get_log() | ||
|
@@ -158,6 +163,11 @@ def __init__(self, port, wss_port, tls=True): | |
# keep track of client side connections so they | ||
# can be shut down cleanly: | ||
self.coinjoin_connection = None | ||
# Options for generating a tumble schedule / running the tumbler. | ||
# Doubles as flag for indicating whether we're currently running | ||
# a tumble schedule. | ||
self.tumbler_options = None | ||
self.tumble_log = None | ||
|
||
def get_client_factory(self): | ||
return JMClientProtocolFactory(self.taker) | ||
|
@@ -424,14 +434,27 @@ def dummy_restart_callback(msg): | |
token=self.cookie) | ||
|
||
def taker_finished(self, res, fromtx=False, waittime=0.0, txdetails=None): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess it would have been just as reasonable to make a Of course it brings up that whole thing - the 'sendpayment vs tumbler' distinction is to my mind no longer the right distinction to make - there are only coinjoin schedules at the lower layer, with an offshoot being the I could argue this is perhaps an error; if in future we want the API to support any arbitrary schedule, then it would naturally fit under There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree completely on the point about 'sendpayment vs tumbler'. I feel like mid-term this endpoint could evolve into simply taking any schedule via POST body and then executing it. For people who would want to use the default tumbler schedule, we could provide another endpoint For now though I feel like the all-in-one function as it is now is probably the best (the easiest) way to start. |
||
# This is a slimmed down version compared with what is seen in | ||
# the CLI code, since that code encompasses schedules with multiple | ||
# entries; for now, the RPC only supports single joins. | ||
# TODO this may be updated. | ||
# It is also different in that the event loop must not shut down | ||
# when processing finishes. | ||
|
||
# reset our state on completion, we are no longer coinjoining: | ||
if not self.tumbler_options: | ||
# We were doing a single coinjoin -- stop taker. | ||
self.stop_taker(res) | ||
else: | ||
# We're running the tumbler. | ||
assert self.tumble_log is not None | ||
|
||
logsdir = os.path.join(os.path.dirname(jm_single().config_location), "logs") | ||
sfile = os.path.join(logsdir, self.tumbler_options['schedulefile']) | ||
|
||
tumbler_taker_finished_update(self.taker, sfile, self.tumble_log, self.tumbler_options, res, fromtx, waittime, txdetails) | ||
|
||
if not fromtx: | ||
# The tumbling schedule's final transaction is done. | ||
self.stop_taker(res) | ||
self.tumbler_options = None | ||
elif fromtx != "unconfirmed": | ||
# A non-final transaction in the tumbling schedule is done -- continue schedule after wait time. | ||
reactor.callLater(waittime*60, self.clientfactory.getClient().clientStart) | ||
|
||
def stop_taker(self, res): | ||
self.taker = None | ||
|
||
if not res: | ||
|
@@ -464,6 +487,9 @@ def filter_orders_callback(self,orderfees, cjamount): | |
""" | ||
return True | ||
|
||
def filter_orders_callback_tumbler(self, orders_fees, cjamount): | ||
return tumbler_filter_orders_callback(orders_fees, cjamount, self.taker) | ||
|
||
def check_daemon_ready(self): | ||
# daemon must be up before coinjoins start. | ||
daemon_serving_host, daemon_serving_port = get_daemon_serving_params() | ||
|
@@ -1023,3 +1049,113 @@ def getseed(self, request, walletname): | |
raise InvalidRequestFormat() | ||
seedphrase, _ = self.services["wallet"].get_mnemonic_words() | ||
return make_jmwalletd_response(request, seedphrase=seedphrase) | ||
|
||
@app.route('/wallet/<string:walletname>/taker/schedule', methods=['POST']) | ||
def start_tumbler(self, request, walletname): | ||
self.check_cookie(request) | ||
|
||
if not self.services["wallet"]: | ||
raise NoWalletFound() | ||
if not self.wallet_name == walletname: | ||
raise InvalidRequestFormat() | ||
|
||
# -- Options parsing ----------------------------------------------- | ||
|
||
(options, args) = get_tumbler_parser().parse_args([]) | ||
AdamISZ marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.tumbler_options = vars(options) | ||
|
||
# At the moment only the destination addresses can be set. | ||
# For now, all other options are hardcoded to the defaults of | ||
# the tumbler.py script. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is a sensible simplification for a first step. Obviously in future we can |
||
request_data = self.get_POST_body(request, ["destination_addresses"]) | ||
if not request_data: | ||
raise InvalidRequestFormat() | ||
|
||
destaddrs = request_data["destination_addresses"] | ||
for daddr in destaddrs: | ||
success, _ = validate_address(daddr) | ||
if not success: | ||
raise InvalidRequestFormat() | ||
|
||
# Setting max_cj_fee based on global config. | ||
# We won't respect it being set via tumbler_options for now. | ||
def dummy_user_callback(rel, abs): | ||
raise ConfigNotPresent() | ||
|
||
max_cj_fee = get_max_cj_fee_values(jm_single().config, | ||
None, | ||
user_callback=dummy_user_callback) | ||
|
||
jm_single().mincjamount = self.tumbler_options['mincjamount'] | ||
|
||
# -- Schedule generation ------------------------------------------- | ||
|
||
# Always generates a new schedule. No restart support for now. | ||
schedule = get_tumble_schedule(self.tumbler_options, | ||
destaddrs, | ||
self.services["wallet"].get_balance_by_mixdepth()) | ||
|
||
logsdir = os.path.join(os.path.dirname(jm_single().config_location), | ||
"logs") | ||
sfile = os.path.join(logsdir, self.tumbler_options['schedulefile']) | ||
with open(sfile, "wb") as f: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: I wonder why this is "wb" instead of "w" since the schedule file is plain text? https://stackoverflow.com/a/7085623 (Edit: Yes, I meant "w", not "b" - corrected the comment.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You mean why is it "wb" instead of "w" right? Good catch! I have to say I just blindly took what was already there in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah just leave it for now, I guess it's an oversight because it is indeed a text format. |
||
f.write(schedule_to_text(schedule)) | ||
|
||
if self.tumble_log is None: | ||
self.tumble_log = get_tumble_log(logsdir) | ||
|
||
self.tumble_log.info("TUMBLE STARTING") | ||
self.tumble_log.info("With this schedule: ") | ||
self.tumble_log.info(pprint.pformat(schedule)) | ||
|
||
# -- Running the Taker --------------------------------------------- | ||
|
||
# For simplicity, we're not doing any fee estimation for now. | ||
# We might want to add fee estimation (see scripts/tumbler.py) to | ||
# prevent users from overspending on fees when tumbling with small | ||
# amounts. | ||
|
||
if not self.activate_coinjoin_state(CJ_TAKER_RUNNING): | ||
raise ServiceAlreadyStarted() | ||
|
||
self.taker = Taker(self.services["wallet"], | ||
schedule, | ||
max_cj_fee=max_cj_fee, | ||
order_chooser=self.tumbler_options['order_choose_fn'], | ||
callbacks=(self.filter_orders_callback_tumbler, None, | ||
AdamISZ marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.taker_finished), | ||
tdestaddrs=destaddrs) | ||
self.clientfactory = self.get_client_factory() | ||
|
||
self.taker.testflag = True | ||
|
||
dhost, dport = self.check_daemon_ready() | ||
|
||
_, self.coinjoin_connection = start_reactor(dhost, | ||
dport, | ||
self.clientfactory, | ||
rs=False) | ||
|
||
return make_jmwalletd_response(request, status=202, schedule=schedule) | ||
|
||
@app.route('/wallet/<string:walletname>/taker/schedule', methods=['GET']) | ||
def get_tumbler_schedule(self, request, walletname): | ||
self.check_cookie(request) | ||
|
||
if not self.services["wallet"]: | ||
raise NoWalletFound() | ||
if not self.wallet_name == walletname: | ||
raise InvalidRequestFormat() | ||
|
||
if not self.tumbler_options: | ||
return make_jmwalletd_response(request, status=404) | ||
|
||
|
||
logsdir = os.path.join(os.path.dirname(jm_single().config_location), "logs") | ||
sfile = os.path.join(logsdir, self.tumbler_options['schedulefile']) | ||
res, schedule = get_schedule(sfile) | ||
|
||
if not res: | ||
return make_jmwalletd_response(request, status=500) | ||
|
||
return make_jmwalletd_response(request, schedule=schedule) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As per comment to the code, it's strange that we are describing and naming this after a procedure to pass in a schedule and run it, rather than what it actually currently is - pass in destinations (and perhaps other options in future), and let the code construct the schedule according to the tumbler algorithm.