From 3f358edf8ff5d2d7cfeb7f5db451c944f5fc11bd Mon Sep 17 00:00:00 2001 From: Daniel <10026790+dnlggr@users.noreply.github.com> Date: Wed, 27 Apr 2022 15:54:59 +0200 Subject: [PATCH] Add options to scheduler API endpoint --- docs/api/wallet-rpc.yaml | 54 ++++++++++++++- jmclient/jmclient/wallet_rpc.py | 112 ++++++++++++++++++++++++++------ 2 files changed, 144 insertions(+), 22 deletions(-) diff --git a/docs/api/wallet-rpc.yaml b/docs/api/wallet-rpc.yaml index e666755..c718fb9 100644 --- a/docs/api/wallet-rpc.yaml +++ b/docs/api/wallet-rpc.yaml @@ -605,11 +605,63 @@ components: required: - destinations properties: - destinations: + destination_addresses: type: array items: type: string example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw" + tumbler_options: + type: object + properties: + addrcount: + type: integer + minmakercount: + type: integer + makercountrange: + type: array + items: + type: number + minItems: 2 + maxItems: 2 + example: [9, 1] + mixdepthcount: + type: integer + mintxcount: + type: integer + txcountparams: + type: array + items: + type: number + minItems: 2 + maxItems: 2 + example: [2, 1] + timelambda: + type: number + stage1_timelambda_increase: + type: number + liquiditywait: + type: integer + waittime: + type: number + mixdepthsrc: + type: integer + restart: + type: boolean + schedulefile: + type: string + mincjamount: + type: integer + amtmixdepths: + type: integer + rounding_chance: + type: number + rounding_sigfig_weights: + type: array + items: + type: number + minItems: 5 + maxItems: 5 + example: [55, 15, 25, 65, 40] StartMakerRequest: type: object required: diff --git a/jmclient/jmclient/wallet_rpc.py b/jmclient/jmclient/wallet_rpc.py index 8cc5ea1..e996559 100644 --- a/jmclient/jmclient/wallet_rpc.py +++ b/jmclient/jmclient/wallet_rpc.py @@ -92,6 +92,11 @@ class TransactionFailed(Exception): class NotEnoughCoinsForMaker(Exception): pass +# raised when we tried to start the tumbler, +# but the wallet was empty/not enough. +class NotEnoughCoinsForTumbler(Exception): + pass + # raised when we cannot read data from our # yigen-statement csv file: class YieldGeneratorDataUnreadable(Exception): @@ -167,6 +172,7 @@ class JMWalletDaemon(Service): # 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) @@ -332,6 +338,12 @@ class JMWalletDaemon(Service): request.setResponseCode(409) return self.err(request, "Maker could not start, no confirmed coins.") + @app.handle_errors(NotEnoughCoinsForTumbler) + def not_enough_coins_tumbler(self, request, failure): + # as above, 409 may not be ideal + request.setResponseCode(409) + return self.err(request, "Tumbler could not start, no confirmed coins.") + @app.handle_errors(YieldGeneratorDataUnreadable) def yieldgenerator_report_unavailable(self, request, failure): request.setResponseCode(404) @@ -369,21 +381,48 @@ class JMWalletDaemon(Service): # startup, so any failure to exist here is a logic error: self.wss_factory.valid_token = self.cookie - def get_POST_body(self, request, keys): + def get_POST_body(self, request, required_keys, optional_keys=None): """ given a request object, retrieve values corresponding - to keys keys in a dict, assuming they were encoded using JSON. - If *any* of the keys are not present, return False, else - returns a dict of those key-value pairs. + to keys in a dict, assuming they were encoded using JSON. + If *any* of the required_keys are not present or required_keys + and optional_keys clash, return False, else returns a dict of those + key-value pairs and any of the optional key-value pairs. """ assert isinstance(request.content, BytesIO) # we swallow any formatting failure here: try: json_data = json.loads(request.content.read().decode( "utf-8")) - return {k: json_data[k] for k in keys} + required_body = {k: json_data[k] for k in required_keys} + + if optional_keys is not None: + optional_body = {k: json_data[k] for k in optional_keys if k in json_data} + + for key in optional_body: + if not key in required_body: + required_body[key] = optional_body[key] + else: + return False + + return required_body except: return False + def parse_tumbler_options_from_json(self, tumbler_options_json): + parsed_tumbler_options = {} + + for key, val in tumbler_options_json.items(): + if isinstance(val, list): + # Convert JSON lists to tuples. + parsed_tumbler_options[key] = tuple(val) + elif key == 'order_choose_fn': + # Todo: No support for custom order choose function via API yet. + pass + else: + parsed_tumbler_options[key] = val + + return parsed_tumbler_options + def initialize_wallet_service(self, request, wallet, wallet_name, **kwargs): """ Called only when the wallet has loaded correctly, so authorization is passed, so set cookie for this wallet @@ -438,11 +477,12 @@ class JMWalletDaemon(Service): 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") - tumble_log = get_tumble_log(logsdir) sfile = os.path.join(logsdir, self.tumbler_options['schedulefile']) - tumbler_taker_finished_update(self.taker, sfile, tumble_log, self.tumbler_options, res, fromtx, waittime, txdetails) + 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. @@ -1052,6 +1092,10 @@ class JMWalletDaemon(Service): def start_tumbler(self, request, walletname): self.check_cookie(request) + if self.coinjoin_state is not CJ_NOT_RUNNING or self.tumbler_options is not None: + # Tumbler or taker seems to be running already. + return make_jmwalletd_response(request, status=409) + if not self.services["wallet"]: raise NoWalletFound() if not self.wallet_name == walletname: @@ -1059,13 +1103,12 @@ class JMWalletDaemon(Service): # -- Options parsing ----------------------------------------------- + # Start with default tumbler options from the tumbler CLI. (options, args) = get_tumbler_parser().parse_args([]) - self.tumbler_options = vars(options) + 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. - request_data = self.get_POST_body(request, ["destination_addresses"]) + request_data = self.get_POST_body(request, ["destination_addresses"], + ["tumbler_options"]) if not request_data: raise InvalidRequestFormat() @@ -1075,6 +1118,14 @@ class JMWalletDaemon(Service): if not success: raise InvalidRequestFormat() + if "tumbler_options" in request_data: + requested_tumbler_options = self.parse_tumbler_options_from_json( + request_data["tumbler_options"]) + + for k in tumbler_options: + if k in requested_tumbler_options: + tumbler_options[k] = requested_tumbler_options[k] + # 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): @@ -1084,25 +1135,43 @@ class JMWalletDaemon(Service): None, user_callback=dummy_user_callback) - jm_single().mincjamount = self.tumbler_options['mincjamount'] + jm_single().mincjamount = tumbler_options['mincjamount'] + + # -- Check wallet balance ------------------------------------------ + + max_mix_depth = tumbler_options['mixdepthsrc'] + tumbler_options['mixdepthcount'] + + if tumbler_options['amtmixdepths'] > max_mix_depth: + max_mix_depth = tumbler_options['amtmixdepths'] + + max_mix_to_tumble = min(tumbler_options['mixdepthsrc'] + tumbler_options['mixdepthcount'], max_mix_depth) + + total_tumble_amount = int(0) + for i in range(tumbler_options['mixdepthsrc'], max_mix_to_tumble): + total_tumble_amount += self.services["wallet"].get_balance_by_mixdepth(verbose=False, minconfs=1)[i] + + if total_tumble_amount == 0: + raise NotEnoughCoinsForTumbler() # -- Schedule generation ------------------------------------------- # Always generates a new schedule. No restart support for now. - schedule = get_tumble_schedule(self.tumbler_options, + schedule = get_tumble_schedule(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']) + sfile = os.path.join(logsdir, tumbler_options['schedulefile']) with open(sfile, "wb") as f: f.write(schedule_to_text(schedule)) - tumble_log = get_tumble_log(logsdir) - tumble_log.info("TUMBLE STARTING") - tumble_log.info("With this schedule: ") - tumble_log.info(pprint.pformat(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 --------------------------------------------- @@ -1114,6 +1183,8 @@ class JMWalletDaemon(Service): if not self.activate_coinjoin_state(CJ_TAKER_RUNNING): raise ServiceAlreadyStarted() + self.tumbler_options = tumbler_options + self.taker = Taker(self.services["wallet"], schedule, max_cj_fee=max_cj_fee, @@ -1143,10 +1214,9 @@ class JMWalletDaemon(Service): if not self.wallet_name == walletname: raise InvalidRequestFormat() - if not self.tumbler_options: + if not self.tumbler_options or not self.coinjoin_state == CJ_TAKER_RUNNING: 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)