bkpr: forward utxo_deposit / utxo_spend notifications to new injectutxodeposit / injectutxospend calls.
What changed, and why it matters
This commit refactors the Core Lightning bookkeeper plugin so that incoming 'utxo_deposit' and 'utxo_spend' notifications are forwarded to new internal 'injectutxodeposit' / 'injectutxospend' RPC calls, then reprocessed as ordinary 'foreign' chain events. It is a code-cleanup and consistency change; it does not by itself introduce or fix a clear security vulnerability, though it changes how injected transactions are recorded and tagged.
Treat as a normal functional refactor. Reviewers should verify that the new injectutxodeposit/injectutxospend endpoints perform equivalent validation and idempotency checks to the removed inline handlers, and that marking events as foreign does not bypass any accounting or fee-calculation logic that should still apply.
Security signals we found
Change in how externally injected UTXO events are classified and logged
New boolean 'foreign' flag added to chain_event structure
Channel-open lookup skipped for foreign/injected events
Refactoring of notification handlers to use internal RPC calls
No explicit bounds, auth, or crypto changes visible in the diff
Evidence from the diff
The patch removes inline database logging from json_utxo_deposit() and json_utxo_spend() in plugins/bkpr/bookkeeper.c and instead constructs JSON-RPC requests to injectutxodeposit / injectutxospend. It adds a ‘foreign’ boolean flag to struct chain_event and sets it when the MVT_FOREIGN tag is present. parse_and_log_chain_move() now skips the channel-open lookup for foreign events. Test expectations are updated to match the new ‘Foreign chain event:’ debug log format. The commit message frames this as absorbing injected UTXO events as normal foreign entries.
Changed components
plugins/bkpr/bookkeeper.cplugins/bkpr/chain_event.htests/test_bookkeeper.pyInspect captured patch +89 / −122
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index e7e1409b..06b10ac7 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -1517,11 +1517,24 @@ parse_and_log_chain_move(struct command *cmd,
e->stealable = false;
e->splice_close = false;
+ e->foreign = false;
for (size_t i = 0; i < tal_count(tags); i++) {
e->stealable |= tags[i] == MVT_STEALABLE;
e->splice_close |= tags[i] == MVT_SPLICE;
+ e->foreign |= tags[i] == MVT_FOREIGN;
}
+ /* For tests, we log these harder! */
+ if (e->foreign)
+ plugin_log(cmd->plugin, LOG_DBG,
+ "Foreign chain event: %s (%s) %s -%s %"PRIu64" %d %s %s",
+ e->tag, acct_name,
+ fmt_amount_msat(tmpctx, e->credit),
+ fmt_amount_msat(tmpctx, e->debit),
+ e->timestamp, e->blockheight,
+ fmt_bitcoin_outpoint(tmpctx, &e->outpoint),
+ e->spending_txid ? fmt_bitcoin_txid(tmpctx, e->spending_txid) : "");
+
db_begin_transaction(bkpr->db);
/* FIXME: lookup the peer id for this channel! */
acct = find_or_create_account(cmd, bkpr, acct_name);
@@ -1574,9 +1587,10 @@ parse_and_log_chain_move(struct command *cmd,
/* If this is a channel account event, it's possible
* that we *never* got the open event. (This happens
* if you add the plugin *after* you've closed the channel) */
- if ((!acct->open_event_db_id && is_channel_account(acct->name))
- || (orig_acct && is_channel_account(orig_acct->name)
- && !orig_acct->open_event_db_id)) {
+ if (!e->foreign
+ && ((!acct->open_event_db_id && is_channel_account(acct->name))
+ || (orig_acct && is_channel_account(orig_acct->name)
+ && !orig_acct->open_event_db_id))) {
/* Find the channel open info for this peer */
struct out_req *req;
struct event_info *info;
@@ -1729,14 +1743,26 @@ static bool json_to_tok(const char *buffer, const jsmntok_t *tok, const jsmntok_
return true;
}
+static struct command_result *inject_done(struct command *notif_cmd,
+ const char *methodname,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused)
+{
+ return notification_handled(notif_cmd);
+}
+
+/* FIXME: Deprecate */
static struct command_result *json_utxo_deposit(struct command *cmd, const char *buf, const jsmntok_t *params)
{
- const char *move_tag ="utxo_deposit";
- struct chain_event *ev = tal(cmd, struct chain_event);
- struct account *acct;
- const char *err;
- struct bkpr *bkpr = bkpr_of(cmd->plugin);
+ const char *acct_name, *origin_acct;
+ struct amount_msat amount;
+ struct bitcoin_outpoint outpoint;
+ u64 timestamp;
+ u32 blockheight;
const jsmntok_t *transfer_from;
+ const char *err;
+ struct out_req *req;
transfer_from = NULL;
err = json_scan(tmpctx, buf, params,
@@ -1748,71 +1774,49 @@ static struct command_result *json_utxo_deposit(struct command *cmd, const char
",timestamp:%"
",blockheight:%"
"}}",
- JSON_SCAN_TAL(ev, json_strdup, &ev->acct_name),
+ JSON_SCAN_TAL(tmpctx, json_strdup, &acct_name),
JSON_SCAN(json_to_tok, &transfer_from),
- JSON_SCAN(json_to_outpoint, &ev->outpoint),
- JSON_SCAN(json_to_msat, &ev->credit),
- JSON_SCAN(json_to_u64, &ev->timestamp),
- JSON_SCAN(json_to_u32, &ev->blockheight));
+ JSON_SCAN(json_to_outpoint, &outpoint),
+ JSON_SCAN(json_to_msat, &amount),
+ JSON_SCAN(json_to_u64, ×tamp),
+ JSON_SCAN(json_to_u32, &blockheight));
if (err)
plugin_err(cmd->plugin,
- "`%s` parameters did not scan %s: %.*s",
- move_tag, err, json_tok_full_len(params),
+ "`utxo_deposit` parameters did not scan %s: %.*s",
+ err, json_tok_full_len(params),
json_tok_full(buf, params));
if (!transfer_from || json_tok_is_null(buf, transfer_from))
- ev->origin_acct = NULL;
+ origin_acct = NULL;
else
- ev->origin_acct = json_strdup(ev, buf, transfer_from);
-
- /* Log the thing */
- db_begin_transaction(bkpr->db);
- acct = find_or_create_account(cmd, bkpr, ev->acct_name);
-
- ev->tag = "deposit";
- ev->stealable = false;
- ev->splice_close = false;
- ev->debit = AMOUNT_MSAT(0);
- ev->output_value = ev->credit;
- ev->spending_txid = NULL;
- ev->payment_id = NULL;
- ev->splice_close = false;
-
- plugin_log(cmd->plugin, LOG_DBG, "%s (%s|%s) %s -%s %"PRIu64" %d %s",
- move_tag, ev->tag, ev->acct_name,
- fmt_amount_msat(tmpctx, ev->credit),
- fmt_amount_msat(tmpctx, ev->debit),
- ev->timestamp, ev->blockheight,
- fmt_bitcoin_outpoint(tmpctx, &ev->outpoint));
-
- if (!log_chain_event(bkpr, acct, ev)) {
- db_commit_transaction(bkpr->db);
- /* This is not a new event, do nothing */
- return notification_handled(cmd);
- }
-
- /* Can we calculate any onchain fees now? */
- err = maybe_update_onchain_fees(cmd, cmd, bkpr, &ev->outpoint.txid);
- db_commit_transaction(bkpr->db);
- if (err)
- plugin_err(cmd->plugin,
- "Unable to update onchain fees %s",
- err);
-
- /* FIXME: do account close checks, when allow onchain close to externals? */
- return notification_handled(cmd);;
+ origin_acct = json_strdup(tmpctx, buf, transfer_from);
+
+ req = jsonrpc_request_start(cmd, "injectutxodeposit",
+ inject_done,
+ plugin_broken_cb,
+ NULL);
+ json_add_string(req->js, "account", acct_name);
+ if (origin_acct)
+ json_add_string(req->js, "transfer_from", origin_acct);
+ json_add_outpoint(req->js, "outpoint", &outpoint);
+ json_add_amount_msat(req->js, "amount_msat", amount);
+ json_add_u64(req->js, "timestamp", timestamp);
+ json_add_u32(req->js, "blockheight", blockheight);
+ return send_outreq(req);
}
static struct command_result *json_utxo_spend(struct command *cmd, const char *buf, const jsmntok_t *params)
{
- const char *move_tag ="utxo_spend";
- struct account *acct;
- struct chain_event *ev = tal(cmd, struct chain_event);
- const char *err, *acct_name;
- struct bkpr *bkpr = bkpr_of(cmd->plugin);
+ const char *acct_name;
+ struct amount_msat amount;
+ struct bitcoin_txid spending_txid;
+ struct bitcoin_outpoint outpoint;
+ u64 timestamp;
+ u32 blockheight;
+ const char *err;
+ struct out_req *req;
- ev->spending_txid = tal(ev, struct bitcoin_txid);
err = json_scan(tmpctx, buf, params,
"{utxo_spend:{"
"account:%"
@@ -1823,69 +1827,29 @@ static struct command_result *json_utxo_spend(struct command *cmd, const char *b
",blockheight:%"
"}}",
JSON_SCAN_TAL(tmpctx, json_strdup, &acct_name),
- JSON_SCAN(json_to_outpoint, &ev->outpoint),
- JSON_SCAN(json_to_txid, ev->spending_txid),
- JSON_SCAN(json_to_msat, &ev->debit),
- JSON_SCAN(json_to_u64, &ev->timestamp),
- JSON_SCAN(json_to_u32, &ev->blockheight));
+ JSON_SCAN(json_to_outpoint, &outpoint),
+ JSON_SCAN(json_to_txid, &spending_txid),
+ JSON_SCAN(json_to_msat, &amount),
+ JSON_SCAN(json_to_u64, ×tamp),
+ JSON_SCAN(json_to_u32, &blockheight));
if (err)
plugin_err(cmd->plugin,
- "`%s` parameters did not scan %s: %.*s",
- move_tag, err, json_tok_full_len(params),
+ "`utxo_spend` parameters did not scan %s: %.*s",
+ err, json_tok_full_len(params),
json_tok_full(buf, params));
- /* Log the thing */
- db_begin_transaction(bkpr->db);
- acct = find_or_create_account(cmd, bkpr, acct_name);
-
- ev->origin_acct = NULL;
- ev->tag = "withdrawal";
- ev->stealable = false;
- ev->splice_close = false;
- ev->credit = AMOUNT_MSAT(0);
- ev->output_value = ev->debit;
- ev->payment_id = NULL;
-
- plugin_log(cmd->plugin, LOG_DBG, "%s (%s|%s) %s -%s %"PRIu64" %d %s %s",
- move_tag, ev->tag, acct_name,
- fmt_amount_msat(tmpctx, ev->credit),
- fmt_amount_msat(tmpctx, ev->debit),
- ev->timestamp, ev->blockheight,
- fmt_bitcoin_outpoint(tmpctx, &ev->outpoint),
- fmt_bitcoin_txid(tmpctx, ev->spending_txid));
-
- if (!log_chain_event(bkpr, acct, ev)) {
- db_commit_transaction(bkpr->db);
- /* This is not a new event, do nothing */
- return notification_handled(cmd);
- }
-
- err = maybe_update_onchain_fees(cmd, cmd, bkpr, ev->spending_txid);
- if (err) {
- db_commit_transaction(bkpr->db);
- plugin_err(cmd->plugin,
- "Unable to update onchain fees %s",
- err);
- }
-
- err = maybe_update_onchain_fees(cmd, cmd, bkpr, &ev->outpoint.txid);
- if (err) {
- db_commit_transaction(bkpr->db);
- plugin_err(cmd->plugin,
- "Unable to update onchain fees %s",
- err);
- }
-
- /* Go see if there's any deposits to an external
- * that are now confirmed */
- /* FIXME: might need updating when we can splice? */
- maybe_closeout_external_deposits(cmd, bkpr, ev->spending_txid,
- ev->blockheight);
- db_commit_transaction(bkpr->db);
-
- /* FIXME: do account close checks, when allow onchain close to externals? */
- return notification_handled(cmd);;
+ req = jsonrpc_request_start(cmd, "injectutxospend",
+ inject_done,
+ plugin_broken_cb,
+ NULL);
+ json_add_string(req->js, "account", acct_name);
+ json_add_outpoint(req->js, "outpoint", &outpoint);
+ json_add_txid(req->js, "spending_txid", &spending_txid);
+ json_add_amount_msat(req->js, "amount_msat", amount);
+ json_add_u64(req->js, "timestamp", timestamp);
+ json_add_u32(req->js, "blockheight", blockheight);
+ return send_outreq(req);
}
static struct command_result *json_coin_moved(struct command *cmd,
diff --git a/plugins/bkpr/chain_event.h b/plugins/bkpr/chain_event.h
index 267f4bc1..0096ffca 100644
--- a/plugins/bkpr/chain_event.h
+++ b/plugins/bkpr/chain_event.h
@@ -33,6 +33,9 @@ struct chain_event {
* confirmation? */
bool splice_close;
+ /* Injected? */
+ bool foreign;
+
/* Amount we received in this event */
struct amount_msat credit;
diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py
index 27a339fe..615bafdd 100644
--- a/tests/test_bookkeeper.py
+++ b/tests/test_bookkeeper.py
@@ -909,9 +909,9 @@ def test_bookkeeper_custom_notifs(node_factory, chainparams):
acct = "nifty's secret stash"
l1.rpc.senddeposit(acct, False, outpoint_in, amount)
+ l1.daemon.wait_for_log(r"Foreign chain event: deposit \(nifty's secret stash\) 180000000msat -0msat 1679955976 111 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:0")
l1.rpc.sendspend(acct, outpoint_in, spend_txid, amount)
- l1.daemon.wait_for_log(r"utxo_deposit \(deposit|nifty's secret stash\) .* -0msat 1679955976 111 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:0")
- l1.daemon.wait_for_log(r"utxo_spend \(withdrawal|nifty's secret stash\) 0msat -12345678000msat 1679955976 111 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:0 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+ l1.daemon.wait_for_log(r"Foreign chain event: withdrawal \(nifty's secret stash\) 0msat -180000000msat 1679955976 111 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:0 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
# balance should be zero
bals = l1.rpc.bkpr_listbalances()['accounts']
@@ -921,7 +921,7 @@ def test_bookkeeper_custom_notifs(node_factory, chainparams):
assert only_one(bal['balances'])['balance_msat'] == Millisatoshi(0)
l1.rpc.senddeposit(acct, False, change_deposit, amount - withdraw_amt - fee)
- l1.daemon.wait_for_log(r"utxo_deposit \(deposit|nifty's secret stash\) .* -0msat 1679955976 111 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:0")
+ l1.daemon.wait_for_log(r"Foreign chain event: deposit \(nifty's secret stash\) .* -0msat 1679955976 111 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:0")
# balance should be equal to amount
events = l1.rpc.bkpr_listaccountevents(acct)['events']
@@ -933,7 +933,7 @@ def test_bookkeeper_custom_notifs(node_factory, chainparams):
assert onchain_fee_one == fee + withdraw_amt
l1.rpc.senddeposit(acct, True, external_deposit, withdraw_amt)
- l1.daemon.wait_for_log(r"utxo_deposit \(deposit|external\) .* -0msat 1679955976 111 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:1")
+ l1.daemon.wait_for_log(r"Foreign chain event: deposit \(external\) .* -0msat 1679955976 111 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:1")
events = l1.rpc.bkpr_listaccountevents(acct)['events']
onchain_fees = [x for x in events if x['type'] == 'onchain_fee']
assert len(onchain_fees) == 2
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.