diff --git a/doc/PLUGINS.md b/doc/PLUGINS.md index 3a52fa67ccbb..072df037bd98 100644 --- a/doc/PLUGINS.md +++ b/doc/PLUGINS.md @@ -237,9 +237,8 @@ above for example subscribes to the two topics `connect` and `disconnect`. The topics that are currently defined and the corresponding payloads are listed below. -### Notification Types -#### `channel_opened` +### `channel_opened` A notification for topic `channel_opened` is sent if a peer successfully funded a channel with us. It contains the peer id, the funding amount (in millisatoshis), the funding @@ -257,7 +256,7 @@ into a block. } ``` -#### `connect` +### `connect` A notification for topic `connect` is sent every time a new connection to a peer is established. @@ -269,7 +268,7 @@ to a peer is established. } ``` -#### `disconnect` +### `disconnect` A notification for topic `disconnect` is sent every time a connection to a peer was lost. @@ -280,7 +279,7 @@ to a peer was lost. } ``` -#### `invoice_payment` +### `invoice_payment` A notification for topic `invoice_payment` is sent every time an invoie is paid. @@ -294,7 +293,7 @@ A notification for topic `invoice_payment` is sent every time an invoie is paid. } ``` -#### `warning` +### `warning` A notification for topic `warning` is sent every time a new `BROKEN` /`UNUSUAL` level(in plugins, we use `error`/`warn`) log generated, @@ -322,7 +321,7 @@ forms: `jcon fd :`, `plugin-manager`; 4. `log` is the context of the original log entry. -#### `forward_event` +### `forward_event` A notification for topic `forward_event` is sent every time the status of a forward payment is set. The json format is same as the API @@ -405,7 +404,7 @@ or only `settled` and `failed` case contain `resolved_time`; - The `failcode` and `failreason` are defined in [BOLT 4][bolt4-failure-codes]. -#### `sendpay_success` +### `sendpay_success` A notification for topic `sendpay_success` is sent every time a sendpay succeeds (with `complete` status). The json is the same as the return value of @@ -432,7 +431,7 @@ returns the result of sendpay in specified time or timeout, but `sendpay_success` will always return the result anytime when sendpay successes if is was subscribed. -#### `sendpay_failure` +### `sendpay_failure` A notification for topic `sendpay_failure` is sent every time a sendpay completes with `failed` status. The JSON is same as the return value of @@ -476,6 +475,28 @@ declares that it'd like to be consulted on what to do next for certain events in the daemon. A hook can then decide how `lightningd` should react to the given event. +The call semantics of the hooks, i.e., when and how hooks are called, depend +on the hook type. Most hooks are currently set to `single`-mode. In this mode +only a single plugin can register the hook, and that plugin will get called +for each event of that type. If a second plugin attempts to register the hook +it gets killed and a corresponding log entry will be added to the logs. In +`chain`-mode multiple plugins can register for the hook type and they are +called sequentially if a matching event is triggered. Each plugin can then +handle the event or defer by returning a `continue` result like the following: + +```json +{ + "result": "continue" +} +``` + +The remainder of the response is ignored and if there are any more plugins +that have registered the hook the next one gets called. If there are no more +plugins then the internal handling is resumed as if no hook had been +called. Any other result returned by a plugin is considered an exit from the +chain. Upon exit no more plugin hooks are called for the current event, and +the result is executed. Unless otherwise stated all hooks are `single`-mode. + Hooks and notifications are very similar, however there are a few key differences: @@ -483,10 +504,11 @@ key differences: notifications but not wait for the plugin to process them. Hooks on the other hand are synchronous, `lightningd` cannot finish processing the event until the plugin has returned. - - Any number of plugins can subscribe to a notification topic, - however only one plugin may register for any hook topic at any - point in time (we cannot disambiguate between multiple plugins - returning contradictory results from a hook callback). + - Any number of plugins can subscribe to a notification topic and get + notified in parallel, however only one plugin may register for + `single`-mode hook types, and in all cases only one plugin may return a + non-`continue` response. This avoids having multiple contradictory + responses. Hooks are considered to be an advanced feature due to the fact that `lightningd` relies on the plugin to tell it what to do next. Use them @@ -497,9 +519,7 @@ As a convention, for all hooks, returning the object `{ "result" : "continue" }` results in `lightningd` behaving exactly as if no plugin is registered on the hook. -### Hook Types - -#### `peer_connected` +### `peer_connected` This hook is called whenever a peer has connected and successfully completed the cryptographic handshake. The parameters have the following structure if there is a channel with the peer: @@ -527,7 +547,7 @@ there's a member `error_message`, that member is sent to the peer before disconnection. -#### `db_write` +### `db_write` This hook is called whenever a change is about to be committed to the database. It is currently extremely restricted: @@ -600,7 +620,7 @@ to error without committing to the database! This is the expected way to halt and catch fire. -#### `invoice_payment` +### `invoice_payment` This hook is called whenever a valid payment for an unpaid invoice has arrived. @@ -622,7 +642,7 @@ nodes in [BOLT 4][bolt4-failure-codes], a `result` field with the string `result` field with the string `continue` to accept the payment. -#### `openchannel` +### `openchannel` This hook is called whenever a remote peer tries to fund a channel to us, and it has passed basic sanity checks: @@ -668,7 +688,7 @@ e.g. Note that `close_to` must be a valid address for the current chain; an invalid address will cause the node to exit with an error. -#### `htlc_accepted` +### `htlc_accepted` The `htlc_accepted` hook is called whenever an incoming HTLC is accepted, and its result determines how `lightningd` should treat that HTLC. @@ -769,8 +789,14 @@ processed before the HTLC was forwarded, failed, or resolved, then the plugin may see the same HTLC again during startup. It is therefore paramount that the plugin is idempotent if it talks to an external system. +The `htlc_accepted` hook is a chained hook, i.e., multiple plugins can +register it, and they will be called in the order they were registered in +until the first plugin return a result that is not `{"result": "continue"}`, +after which the event is considered to be handled. After the event has been +handled the remaining plugins will be skipped. + -#### `rpc_command` +### `rpc_command` The `rpc_command` hook allows a plugin to take over any RPC command. It sends the received JSON-RPC request to the registered plugin, @@ -837,7 +863,7 @@ Return a custom error to the request sender: ``` -#### `custommsg` +### `custommsg` The `custommsg` plugin hook is the receiving counterpart to the [`dev-sendcustommsg`][sendcustommsg] RPC method and allows plugins to handle diff --git a/lightningd/invoice.c b/lightningd/invoice.c index 7a0e6e5787ea..5f27ff88612b 100644 --- a/lightningd/invoice.c +++ b/lightningd/invoice.c @@ -269,6 +269,7 @@ invoice_payment_hook_cb(struct invoice_payment_hook_payload *payload, } REGISTER_PLUGIN_HOOK(invoice_payment, + PLUGIN_HOOK_SINGLE, invoice_payment_hook_cb, struct invoice_payment_hook_payload *, invoice_payment_serialize, diff --git a/lightningd/jsonrpc.c b/lightningd/jsonrpc.c index d2f89690495f..fae55e6ac39c 100644 --- a/lightningd/jsonrpc.c +++ b/lightningd/jsonrpc.c @@ -753,7 +753,8 @@ rpc_command_hook_callback(struct rpc_command_hook_payload *p, "Bad response to 'rpc_command' hook.")); } -REGISTER_PLUGIN_HOOK(rpc_command, rpc_command_hook_callback, +REGISTER_PLUGIN_HOOK(rpc_command, PLUGIN_HOOK_SINGLE, + rpc_command_hook_callback, struct rpc_command_hook_payload *, rpc_command_hook_serialize, struct rpc_command_hook_payload *); diff --git a/lightningd/opening_control.c b/lightningd/opening_control.c index 810a45fe57d9..4654b0b63229 100644 --- a/lightningd/opening_control.c +++ b/lightningd/opening_control.c @@ -821,6 +821,7 @@ static void openchannel_hook_cb(struct openchannel_hook_payload *payload, } REGISTER_PLUGIN_HOOK(openchannel, + PLUGIN_HOOK_SINGLE, openchannel_hook_cb, struct openchannel_hook_payload *, openchannel_hook_serialize, diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c index bda3ff4741e8..3ccc8dec505c 100644 --- a/lightningd/peer_control.c +++ b/lightningd/peer_control.c @@ -908,7 +908,8 @@ peer_connected_hook_cb(struct peer_connected_hook_payload *payload, tal_free(payload); } -REGISTER_PLUGIN_HOOK(peer_connected, peer_connected_hook_cb, +REGISTER_PLUGIN_HOOK(peer_connected, PLUGIN_HOOK_SINGLE, + peer_connected_hook_cb, struct peer_connected_hook_payload *, peer_connected_serialize, struct peer_connected_hook_payload *); @@ -2393,8 +2394,9 @@ static void custommsg_payload_serialize(struct custommsg_payload *payload, json_add_node_id(stream, "peer_id", &payload->peer_id); } -REGISTER_PLUGIN_HOOK(custommsg, custommsg_callback, struct custommsg_payload *, - custommsg_payload_serialize, struct custommsg_payload *); +REGISTER_PLUGIN_HOOK(custommsg, PLUGIN_HOOK_SINGLE, custommsg_callback, + struct custommsg_payload *, custommsg_payload_serialize, + struct custommsg_payload *); void handle_custommsg_in(struct lightningd *ld, const struct node_id *peer_id, const u8 *msg) diff --git a/lightningd/peer_htlcs.c b/lightningd/peer_htlcs.c index fa13406630ad..116ffd62227b 100644 --- a/lightningd/peer_htlcs.c +++ b/lightningd/peer_htlcs.c @@ -873,7 +873,8 @@ htlc_accepted_hook_callback(struct htlc_accepted_hook_payload *request, tal_free(request); } -REGISTER_PLUGIN_HOOK(htlc_accepted, htlc_accepted_hook_callback, +REGISTER_PLUGIN_HOOK(htlc_accepted, PLUGIN_HOOK_CHAIN, + htlc_accepted_hook_callback, struct htlc_accepted_hook_payload *, htlc_accepted_hook_serialize, struct htlc_accepted_hook_payload *); diff --git a/lightningd/plugin_hook.c b/lightningd/plugin_hook.c index 064735e28a04..3418b53d52b6 100644 --- a/lightningd/plugin_hook.c +++ b/lightningd/plugin_hook.c @@ -9,8 +9,11 @@ /* Struct containing all the information needed to deserialize and * dispatch an eventual plugin_hook response. */ struct plugin_hook_request { + struct plugin *plugin; + int current_plugin; const struct plugin_hook *hook; void *cb_arg; + void *payload; struct db *db; }; @@ -33,26 +36,44 @@ bool plugin_hook_register(struct plugin *plugin, const char *method) if (!hook) { /* No such hook name registered */ return false; - } else if (hook->plugin != NULL) { - /* Another plugin already registered for this name */ - return false; } - hook->plugin = plugin; + + /* Make sure the plugins array is initialized. */ + if (hook->plugins == NULL) + hook->plugins = notleak(tal_arr(NULL, struct plugin *, 0)); + + /* If this is a single type hook and we have a plugin registered we + * must fail this attempt to add the plugin to the hook. */ + if (hook->type == PLUGIN_HOOK_SINGLE && tal_count(hook->plugins) > 0) + return false; + + /* Ensure we don't register the same plugin multple times. */ + for (size_t i=0; iplugins); i++) + if (hook->plugins[i] == plugin) + return true; + + /* Ok, we're sure they can register and they aren't yet registered, so + * register them. */ + tal_arr_expand(&hook->plugins, plugin); return true; } bool plugin_hook_unregister(struct plugin *plugin, const char *method) { struct plugin_hook *hook = plugin_hook_by_name(method); - if (!hook) { + + if (!hook || !hook->plugins) { /* No such hook name registered */ return false; - } else if (hook->plugin == NULL) { - /* This name is not registered */ - return false; } - hook->plugin = NULL; - return true; + + for (size_t i = 0; i < tal_count(hook->plugins); i++) { + if (hook->plugins[i] == plugin) { + tal_arr_remove(&hook->plugins, i); + return true; + } + } + return false; } void plugin_hook_unregister_all(struct plugin *plugin) @@ -63,10 +84,12 @@ void plugin_hook_unregister_all(struct plugin *plugin) hooks = autodata_get(hooks, &num_hooks); for (size_t i = 0; i < num_hooks; i++) - if (hooks[i]->plugin == plugin) - hooks[i]->plugin = NULL; + plugin_hook_unregister(plugin, hooks[i]->name); } +/* Mutual recursion */ +static void plugin_hook_call_next(struct plugin_hook_request *ph_req); + /** * Callback to be passed to the jsonrpc_request. * @@ -77,45 +100,67 @@ static void plugin_hook_callback(const char *buffer, const jsmntok_t *toks, const jsmntok_t *idtok, struct plugin_hook_request *r) { - const jsmntok_t *resulttok = json_get_member(buffer, toks, "result"); + const jsmntok_t *resulttok, *resrestok; struct db *db = r->db; - struct plugin_destroyed *pd; + bool more_plugins = r->current_plugin + 1 < tal_count(r->hook->plugins); + + resulttok = json_get_member(buffer, toks, "result"); if (!resulttok) fatal("Plugin for %s returned non-result response %.*s", - r->hook->name, - toks->end - toks->start, buffer + toks->start); - - /* If command is "plugin stop", this can free r! */ - pd = plugin_detect_destruction(r->hook->plugin); - db_begin_transaction(db); - r->hook->response_cb(r->cb_arg, buffer, resulttok); - db_commit_transaction(db); - if (!was_plugin_destroyed(pd)) + r->hook->name, toks->end - toks->start, + buffer + toks->start); + + resrestok = json_get_member(buffer, resulttok, "result"); + + /* If this is a hook response containing a `continue` and we have more + * plugins queue the next call. In that case we discard the remainder + * of the result, and let the next plugin decide. */ + if (resrestok && json_tok_streq(buffer, resrestok, "continue") && + more_plugins) { + plugin_hook_call_next(r); + } else { + db_begin_transaction(db); + r->hook->response_cb(r->cb_arg, buffer, resulttok); + db_commit_transaction(db); tal_free(r); + } +} + +static void plugin_hook_call_next(struct plugin_hook_request *ph_req) +{ + struct jsonrpc_request *req; + const struct plugin_hook *hook = ph_req->hook; + ph_req->current_plugin++; + assert(ph_req->current_plugin < tal_count(hook->plugins)); + ph_req->plugin = ph_req->hook->plugins[ph_req->current_plugin]; + + req = jsonrpc_request_start(NULL, hook->name, + plugin_get_log(ph_req->plugin), + plugin_hook_callback, ph_req); + + hook->serialize_payload(ph_req->payload, req->stream); + jsonrpc_request_end(req); + plugin_request_send(ph_req->plugin, req); } void plugin_hook_call_(struct lightningd *ld, const struct plugin_hook *hook, void *payload, void *cb_arg) { - struct jsonrpc_request *req; struct plugin_hook_request *ph_req; - if (hook->plugin) { + if (tal_count(hook->plugins)) { /* If we have a plugin that has registered for this * hook, serialize and call it */ /* FIXME: technically this is a leak, but we don't * currently have a list to store these. We might want * to eventually to inspect in-flight requests. */ - ph_req = notleak(tal(hook->plugin, struct plugin_hook_request)); - req = jsonrpc_request_start(NULL, hook->name, - plugin_get_log(hook->plugin), - plugin_hook_callback, ph_req); + ph_req = notleak(tal(hook->plugins, struct plugin_hook_request)); ph_req->hook = hook; ph_req->cb_arg = cb_arg; ph_req->db = ld->wallet->db; - hook->serialize_payload(payload, req->stream); - jsonrpc_request_end(req); - plugin_request_send(hook->plugin, req); + ph_req->payload = tal_steal(ph_req, payload); + ph_req->current_plugin = -1; + plugin_hook_call_next(ph_req); } else { /* If no plugin has registered for this hook, just * call the callback with a NULL result. Saves us the @@ -130,7 +175,8 @@ void plugin_hook_call_(struct lightningd *ld, const struct plugin_hook *hook, * annoying, and to make it clear that it's totally synchronous. */ /* Special synchronous hook for db */ -static struct plugin_hook db_write_hook = { "db_write", NULL, NULL, NULL }; +static struct plugin_hook db_write_hook = {"db_write", PLUGIN_HOOK_SINGLE, NULL, + NULL, NULL}; AUTODATA(hooks, &db_write_hook); static void db_hook_response(const char *buffer, const jsmntok_t *toks, @@ -192,18 +238,21 @@ void plugin_hook_db_sync(struct db *db) struct jsonrpc_request *req; struct plugin_hook_request *ph_req; void *ret; + struct plugin *plugin; const char **changes = db_changes(db); - if (!hook->plugin) + if (tal_count(hook->plugins) == 0) return; - ph_req = notleak(tal(hook->plugin, struct plugin_hook_request)); + ph_req = notleak(tal(hook->plugins, struct plugin_hook_request)); /* FIXME: do IO logging for this! */ req = jsonrpc_request_start(NULL, hook->name, NULL, db_hook_response, ph_req); ph_req->hook = hook; ph_req->db = db; + ph_req->current_plugin = 0; + plugin = ph_req->plugin = hook->plugins[ph_req->current_plugin]; json_add_num(req->stream, "data_version", db_data_version_get(db)); @@ -213,14 +262,14 @@ void plugin_hook_db_sync(struct db *db) json_array_end(req->stream); jsonrpc_request_end(req); - plugin_request_send(hook->plugin, req); + plugin_request_send(ph_req->plugin, req); /* We can be called on way out of an io_loop, which is already breaking. * That will make this immediately return; save the break value and call * again, then hand it onwards. */ - ret = plugin_exclusive_loop(hook->plugin); + ret = plugin_exclusive_loop(plugin); if (ret != ph_req) { - void *ret2 = plugin_exclusive_loop(hook->plugin); + void *ret2 = plugin_exclusive_loop(plugin); assert(ret2 == ph_req); io_break(ret); } diff --git a/lightningd/plugin_hook.h b/lightningd/plugin_hook.h index 2c4876625f23..59f957df9bc2 100644 --- a/lightningd/plugin_hook.h +++ b/lightningd/plugin_hook.h @@ -44,13 +44,24 @@ * and callback have the correct type. */ +enum plugin_hook_type { + PLUGIN_HOOK_SINGLE, + PLUGIN_HOOK_CHAIN, +}; + struct plugin_hook { const char *name; + + /* Which type of plugin is this? It'll determine how many plugins can + * register this hook, and how the hooks are called. */ + enum plugin_hook_type type; + void (*response_cb)(void *arg, const char *buffer, const jsmntok_t *toks); void (*serialize_payload)(void *src, struct json_stream *dest); - /* Which plugin has registered this hook? */ - struct plugin *plugin; + /* Which plugins have registered this hook? This is a `tal_arr` + * initialized at creation. */ + struct plugin **plugins; }; AUTODATA_TYPE(hooks, struct plugin_hook); @@ -84,18 +95,20 @@ void plugin_hook_call_(struct lightningd *ld, const struct plugin_hook *hook, * response_cb function accepts the deserialized response format and * an arbitrary extra argument used to maintain context. */ -#define REGISTER_PLUGIN_HOOK(name, response_cb, response_cb_arg_type, \ +#define REGISTER_PLUGIN_HOOK(name, type, response_cb, response_cb_arg_type, \ serialize_payload, payload_type) \ struct plugin_hook name##_hook_gen = { \ stringify(name), \ - typesafe_cb_cast(void (*)(void *, const char *, const jsmntok_t *),\ - void (*)(response_cb_arg_type, \ - const char *, const jsmntok_t *), \ - response_cb), \ + type, \ + typesafe_cb_cast( \ + void (*)(void *, const char *, const jsmntok_t *), \ + void (*)(response_cb_arg_type, const char *, \ + const jsmntok_t *), \ + response_cb), \ typesafe_cb_cast(void (*)(void *, struct json_stream *), \ void (*)(payload_type, struct json_stream *), \ serialize_payload), \ - NULL, /* .plugin */ \ + NULL, /* .plugins */ \ }; \ AUTODATA(hooks, &name##_hook_gen); \ PLUGIN_HOOK_CALL_DEF(name, payload_type, response_cb_arg_type); diff --git a/tests/plugins/hook-chain-even.py b/tests/plugins/hook-chain-even.py new file mode 100755 index 000000000000..59dda00d069e --- /dev/null +++ b/tests/plugins/hook-chain-even.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pyln.client import Plugin +from hashlib import sha256 +from binascii import hexlify + +"""A simple plugin that accepts invoices with "BB"*32 preimages +""" +plugin = Plugin() + + +@plugin.hook('htlc_accepted') +def on_htlc_accepted(htlc, plugin, **kwargs): + preimage = b"\xBB" * 32 + payment_hash = sha256(preimage).hexdigest() + preimage = hexlify(preimage).decode('ASCII') + print("htlc_accepted called for payment_hash {}".format(htlc['payment_hash'])) + + if htlc['payment_hash'] == payment_hash: + return {'result': 'resolve', 'payment_key': preimage} + else: + return {'result': 'continue'} + + +plugin.run() diff --git a/tests/plugins/hook-chain-odd.py b/tests/plugins/hook-chain-odd.py new file mode 100755 index 000000000000..d1134fa8c597 --- /dev/null +++ b/tests/plugins/hook-chain-odd.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pyln.client import Plugin +from hashlib import sha256 +from binascii import hexlify + +"""A simple plugin that accepts invoices with "AA"*32 preimages +""" +plugin = Plugin() + + +@plugin.hook('htlc_accepted') +def on_htlc_accepted(htlc, plugin, **kwargs): + preimage = b"\xAA" * 32 + payment_hash = sha256(preimage).hexdigest() + preimage = hexlify(preimage).decode('ASCII') + print("htlc_accepted called for payment_hash {}".format(htlc['payment_hash'])) + + if htlc['payment_hash'] == payment_hash: + return {'result': 'resolve', 'payment_key': preimage} + else: + return {'result': 'continue'} + + +plugin.run() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index d090d99a6a92..180b2526168c 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,6 +1,7 @@ from collections import OrderedDict from fixtures import * # noqa: F401,F403 from flaky import flaky # noqa: F401 +from hashlib import sha256 from pyln.client import RpcError, Millisatoshi from pyln.proto import Invoice from utils import ( @@ -859,3 +860,75 @@ def test_plugin_feature_announce(node_factory): # Check the featurebit set in the `node_announcement` node = l1.rpc.listnodes(l1.info['id'])['nodes'][0] assert(int(node['features'], 16) & (1 << 103) != 0) + + +def test_hook_chaining(node_factory): + """Check that hooks are called in order and the chain exits correctly + + We start two nodes, l2 will have two plugins registering the same hook + (`htlc_accepted`) but handle different cases: + + - the `odd` plugin only handles the "AA"*32 preimage + - the `even` plugin only handles the "BB"*32 preimage + + We check that plugins are called in the order they are registering the + hook, and that they exit the call chain as soon as one plugin returns a + result that isn't `continue`. On exiting the chain the remaining plugins + are not called. If no plugin exits the chain we continue to handle + internally as usual. + + """ + l1, l2 = node_factory.line_graph(2) + + # Start the plugins manually instead of specifying them on the command + # line, otherwise we cannot guarantee the order in which the hooks are + # registered. + p1 = os.path.join(os.path.dirname(__file__), "plugins/hook-chain-odd.py") + p2 = os.path.join(os.path.dirname(__file__), "plugins/hook-chain-even.py") + l2.rpc.plugin_start(p1) + l2.rpc.plugin_start(p2) + + preimage1 = b'\xAA' * 32 + preimage2 = b'\xBB' * 32 + preimage3 = b'\xCC' * 32 + hash1 = sha256(preimage1).hexdigest() + hash2 = sha256(preimage2).hexdigest() + hash3 = sha256(preimage3).hexdigest() + + inv = l2.rpc.invoice(123, 'odd', "Odd payment handled by the first plugin", + preimage="AA" * 32)['bolt11'] + l1.rpc.pay(inv) + + # The first plugin will handle this, the second one should not be called. + assert(l2.daemon.is_in_log( + r'plugin-hook-chain-odd.py: htlc_accepted called for payment_hash {}'.format(hash1) + )) + assert(not l2.daemon.is_in_log( + r'plugin-hook-chain-even.py: htlc_accepted called for payment_hash {}'.format(hash1) + )) + + # The second run is with a payment_hash that `hook-chain-even.py` knows + # about. `hook-chain-odd.py` is called, it returns a `continue`, and then + # `hook-chain-even.py` resolves it. + inv = l2.rpc.invoice( + 123, 'even', "Even payment handled by the second plugin", preimage="BB" * 32 + )['bolt11'] + l1.rpc.pay(inv) + assert(l2.daemon.is_in_log( + r'plugin-hook-chain-odd.py: htlc_accepted called for payment_hash {}'.format(hash2) + )) + assert(l2.daemon.is_in_log( + r'plugin-hook-chain-even.py: htlc_accepted called for payment_hash {}'.format(hash2) + )) + + # And finally an invoice that neither know about, so it should get settled + # by the internal invoice handling. + inv = l2.rpc.invoice(123, 'neither', "Neither plugin handles this", + preimage="CC" * 32)['bolt11'] + l1.rpc.pay(inv) + assert(l2.daemon.is_in_log( + r'plugin-hook-chain-odd.py: htlc_accepted called for payment_hash {}'.format(hash3) + )) + assert(l2.daemon.is_in_log( + r'plugin-hook-chain-even.py: htlc_accepted called for payment_hash {}'.format(hash3) + ))