diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md index 7b867781d..25f80e8e4 100644 --- a/doc/lightningd-config.5.md +++ b/doc/lightningd-config.5.md @@ -538,6 +538,11 @@ network. Add a (taproot) fallback address to invoices produced by the `invoice` command, so they invoices can also be paid onchain. +* **xpay-handle-pay**=*BOOL* [plugin `xpay`, *dynamic*] + + Setting this makes `xpay` intercept simply `pay` commands (default `false`). Note that the +response will be different from the normal pay command, however. + ### Networking options Note that for simple setups, the implicit *autolisten* option does the diff --git a/lightningd/jsonrpc.c b/lightningd/jsonrpc.c index d3c2e4eca..d6c9c4bab 100644 --- a/lightningd/jsonrpc.c +++ b/lightningd/jsonrpc.c @@ -919,7 +919,10 @@ static void replace_command(struct rpc_command_hook_payload *p, fail: was_pending(command_fail(p->cmd, JSONRPC2_INVALID_REQUEST, - "Bad response to 'rpc_command' hook: %s", bad)); + "Bad response to 'rpc_command' hook: %s (%.*s)", + bad, + json_tok_full_len(replacetok), + json_tok_full(buffer, replacetok))); } static void rpc_command_hook_final(struct rpc_command_hook_payload *p STEALS) diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c index a83353979..d50765930 100644 --- a/plugins/xpay/xpay.c +++ b/plugins/xpay/xpay.c @@ -33,6 +33,8 @@ struct xpay { struct pubkey fakenode; /* We need to know current block height */ u32 blockheight; + /* Do we take over "pay" commands? */ + bool take_over_pay; }; static struct xpay *xpay_of(struct plugin *plugin) @@ -1591,13 +1593,122 @@ static const struct plugin_notification notifications[] = { }, }; +static struct command_result *handle_rpc_command(struct command *cmd, + const char *buf, + const jsmntok_t *params) +{ + struct xpay *xpay = xpay_of(cmd->plugin); + const jsmntok_t *rpc_tok, *method_tok, *params_tok, *id_tok, + *bolt11 = NULL, *amount_msat = NULL, *maxfee = NULL, + *partial_msat = NULL, *retry_for = NULL; + struct json_stream *response; + + if (!xpay->take_over_pay) + goto dont_redirect; + + rpc_tok = json_get_member(buf, params, "rpc_command"); + method_tok = json_get_member(buf, rpc_tok, "method"); + params_tok = json_get_member(buf, rpc_tok, "params"); + id_tok = json_get_member(buf, rpc_tok, "id"); + plugin_log(cmd->plugin, LOG_INFORM, "Got command %s", + json_strdup(tmpctx, buf, method_tok)); + + if (!json_tok_streq(buf, method_tok, "pay")) + goto dont_redirect; + + /* Array params? Only handle up to two args (bolt11, msat) */ + if (params_tok->type == JSMN_ARRAY) { + if (params_tok->size != 1 && params_tok->size != 2) { + plugin_log(cmd->plugin, LOG_INFORM, + "Not redirecting pay (only handle 1 or 2 args): %.*s", + json_tok_full_len(params), + json_tok_full(buf, params)); + goto dont_redirect; + } + + bolt11 = params_tok + 1; + if (params_tok->size == 2) + amount_msat = json_next(bolt11); + } else if (params_tok->type == JSMN_OBJECT) { + const jsmntok_t *t; + size_t i; + + json_for_each_obj(i, t, params_tok) { + if (json_tok_streq(buf, t, "bolt11")) + bolt11 = t + 1; + else if (json_tok_streq(buf, t, "amount_msat")) + amount_msat = t + 1; + else if (json_tok_streq(buf, t, "retry_for")) + retry_for = t + 1; + else if (json_tok_streq(buf, t, "maxfee")) + maxfee = t + 1; + else if (json_tok_streq(buf, t, "partial_msat")) + partial_msat = t + 1; + else { + plugin_log(cmd->plugin, LOG_INFORM, + "Not redirecting pay (unknown arg %.*s)", + json_tok_full_len(t), + json_tok_full(buf, t)); + goto dont_redirect; + } + } + if (!bolt11) { + plugin_log(cmd->plugin, LOG_INFORM, + "Not redirecting pay (missing bolt11 parameter)"); + goto dont_redirect; + } + } else { + plugin_log(cmd->plugin, LOG_INFORM, + "Not redirecting pay (unexpected params type)"); + goto dont_redirect; + } + + plugin_log(cmd->plugin, LOG_INFORM, "Redirecting pay->xpay"); + response = jsonrpc_stream_success(cmd); + json_object_start(response, "replace"); + json_add_string(response, "jsonrpc", "2.0"); + json_add_tok(response, "id", id_tok, buf); + json_add_string(response, "method", "xpay"); + json_object_start(response, "params"); + json_add_tok(response, "invstring", bolt11, buf); + if (amount_msat) + json_add_tok(response, "amount_msat", amount_msat, buf); + if (retry_for) + json_add_tok(response, "retry_for", retry_for, buf); + if (maxfee) + json_add_tok(response, "maxfee", maxfee, buf); + if (partial_msat) + json_add_tok(response, "partial_msat", partial_msat, buf); + json_object_end(response); + json_object_end(response); + return command_finished(cmd, response); + +dont_redirect: + return command_hook_success(cmd); +} + +static const struct plugin_hook hooks[] = { + { + "rpc_command", + handle_rpc_command, + }, +}; + int main(int argc, char *argv[]) { - setup_locale(); + struct xpay *xpay; - plugin_main(argv, init, take(tal(NULL, struct xpay)), + setup_locale(); + xpay = tal(NULL, struct xpay); + xpay->take_over_pay = false; + plugin_main(argv, init, take(xpay), PLUGIN_RESTARTABLE, true, NULL, commands, ARRAY_SIZE(commands), notifications, ARRAY_SIZE(notifications), - NULL, 0, NULL, 0, NULL, 0, NULL); + hooks, ARRAY_SIZE(hooks), + NULL, 0, + plugin_option_dynamic("xpay-handle-pay", "bool", + "Make xpay take over pay commands it can handle.", + bool_option, bool_jsonfmt, &xpay->take_over_pay), + NULL); } diff --git a/tests/test_xpay.py b/tests/test_xpay.py index 5d9ebb6ac..a851cd977 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -312,3 +312,103 @@ def test_xpay_partial_msat(node_factory, executor): l1pay.result(TIMEOUT) l3pay.result(TIMEOUT) + + +def test_xpay_takeover(node_factory, executor): + l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True, + opts={'xpay-handle-pay': True, + 'experimental-offers': None}) + + # xpay does NOT look like pay! + l1.rpc.jsonschemas = {} + l2.rpc.jsonschemas = {} + + # Simple bolt11/bolt12 payment. + inv = l3.rpc.invoice(100000, "test_xpay_takeover1", "test_xpay_takeover1")['bolt11'] + l1.rpc.pay(inv) + l1.daemon.wait_for_log('Redirecting pay->xpay') + + # Array version + inv = l3.rpc.invoice(100000, "test_xpay_takeover2", "test_xpay_takeover2")['bolt11'] + subprocess.check_output(['cli/lightning-cli', + '--network={}'.format(TEST_NETWORK), + '--lightning-dir={}' + .format(l1.daemon.lightning_dir), + 'pay', + inv]) + l1.daemon.wait_for_log('Redirecting pay->xpay') + + offer = l3.rpc.offer(100000, "test_xpay_takeover2")['bolt12'] + b12 = l1.rpc.fetchinvoice(offer)['invoice'] + l1.rpc.pay(b12) + l1.daemon.wait_for_log('Redirecting pay->xpay') + + # BOLT11 with amount. + inv = l3.rpc.invoice('any', "test_xpay_takeover3", "test_xpay_takeover3")['bolt11'] + l1.rpc.pay(inv, amount_msat=10000) + l1.daemon.wait_for_log('Redirecting pay->xpay') + + # Array version + inv = l3.rpc.invoice('any', "test_xpay_takeover4", "test_xpay_takeover4")['bolt11'] + subprocess.check_output(['cli/lightning-cli', + '--network={}'.format(TEST_NETWORK), + '--lightning-dir={}' + .format(l1.daemon.lightning_dir), + 'pay', + inv, "10000"]) + l1.daemon.wait_for_log('Redirecting pay->xpay') + + # retry_for, maxfee and partial_msat all work + inv = l3.rpc.invoice('any', "test_xpay_takeover5", "test_xpay_takeover5")['bolt11'] + + fut1 = executor.submit(l1.rpc.pay, bolt11=inv, amount_msat=2000, retry_for=0, maxfee=100, partial_msat=1000) + l1.daemon.wait_for_log('Redirecting pay->xpay') + fut2 = executor.submit(l2.rpc.pay, bolt11=inv, amount_msat=2000, retry_for=0, maxfee=0, partial_msat=1000) + l2.daemon.wait_for_log('Redirecting pay->xpay') + fut1.result(TIMEOUT) + fut2.result(TIMEOUT) + + # Three-array-arg replacements don't work. + inv = l3.rpc.invoice('any', "test_xpay_takeover6", "test_xpay_takeover6")['bolt11'] + subprocess.check_output(['cli/lightning-cli', + '--network={}'.format(TEST_NETWORK), + '--lightning-dir={}' + .format(l1.daemon.lightning_dir), + 'pay', + inv, "10000", 'label']) + l1.daemon.wait_for_log(r'Not redirecting pay \(only handle 1 or 2 args\): ') + + # Other args fail. + inv = l3.rpc.invoice('any', "test_xpay_takeover7", "test_xpay_takeover7") + l1.rpc.pay(inv['bolt11'], amount_msat=10000, label='test_xpay_takeover7') + l1.daemon.wait_for_log(r'Not redirecting pay \(unknown arg \\"label\\"\)') + + inv = l3.rpc.invoice('any', "test_xpay_takeover8", "test_xpay_takeover8") + l1.rpc.pay(inv['bolt11'], amount_msat=10000, riskfactor=1) + l1.daemon.wait_for_log(r'Not redirecting pay \(unknown arg \\"riskfactor\\"\)') + + inv = l3.rpc.invoice('any', "test_xpay_takeover9", "test_xpay_takeover9") + l1.rpc.pay(inv['bolt11'], amount_msat=10000, maxfeepercent=1) + l1.daemon.wait_for_log(r'Not redirecting pay \(unknown arg \\"maxfeepercent\\"\)') + + inv = l3.rpc.invoice('any', "test_xpay_takeover10", "test_xpay_takeover10") + l1.rpc.pay(inv['bolt11'], amount_msat=10000, maxdelay=200) + l1.daemon.wait_for_log(r'Not redirecting pay \(unknown arg \\"maxdelay\\"\)') + + inv = l3.rpc.invoice('any', "test_xpay_takeover11", "test_xpay_takeover11") + l1.rpc.pay(inv['bolt11'], amount_msat=10000, exemptfee=1) + l1.daemon.wait_for_log(r'Not redirecting pay \(unknown arg \\"exemptfee\\"\)') + + # Test that it's really dynamic. + l1.rpc.setconfig('xpay-handle-pay', False) + + # There's no log for this though! + inv = l3.rpc.invoice(100000, "test_xpay_takeover12", "test_xpay_takeover12")['bolt11'] + l1.rpc.pay(inv) + assert not l1.daemon.is_in_log('Redirecting pay->xpay', + start=l1.daemon.logsearch_start) + + l1.rpc.setconfig('xpay-handle-pay', True) + inv = l3.rpc.invoice(100000, "test_xpay_takeover13", "test_xpay_takeover13")['bolt11'] + l1.rpc.pay(inv) + l1.daemon.wait_for_log('Redirecting pay->xpay')