sendpsbt: update channel psbts if this is a channel PSBT.
What changed, and why it matters
This change makes the sendpsbt RPC command smarter: if the transaction being sent matches a PSBT that was promised for a new or spliced Lightning channel, the node now saves the final signed version with that channel so it can re-send it after a restart. It also updates the saved channel PSBT during splice signing. This is a reliability improvement for channel funding and splicing, not a fix for an obvious exploit.
Review whether psbt_equivalent is sufficiently precise to avoid associating an unrelated PSBT with a channel, and ensure the replacement cannot be triggered by a malicious or accidental RPC caller. Consider adding tests for the new association behavior and for splice restart retransmission.
Security signals we found
PSBT equivalence comparison ignores signatures and most PSBT fields, comparing only inputs/outputs
Channel funding PSBT is overwritten when an equivalent PSBT is sent via sendpsbt
Splice signing path now persists updated funding PSBT
No input validation or authorization checks beyond existing RPC access
Evidence from the diff
The patch extends json_sendpsbt in wallet/walletrpc.c to scan all channels and, if the sent PSBT is equivalent to a channel’s unfinalized funding_psbt, replace it with the finalized/signed PSBT and persist it via wallet_channel_save. It also updates channel->funding_psbt in single_splice_signed in lightningd/channel_control.c after a splice is signed. A new psbt_equivalent helper compares inputs (txid, index, sequence) and outputs (amount, script) across PSBT versions. Documentation and msggen schema are updated to describe the new behavior.
Changed components
wallet/walletrpc.c: json_sendpsbt RPC handlerlightningd/channel_control.c: single_splice_signed splice flowdoc/schemas/sendpsbt.jsoncontrib/msggen/msggen/schema.jsonInspect captured patch +113 / −2
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 45669de..f3e23cc 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -32300,7 +32300,7 @@
"rpc": "sendpsbt",
"title": "Command to finalize, extract and send a partially signed bitcoin transaction (PSBT).",
"description": [
- "The **sendpsbt** is a low-level RPC command which sends a fully-signed PSBT."
+ "The **sendpsbt** is a low-level RPC command which sends a fully-signed PSBT. If the PSBT is the same one promised by a channel (via fundchannel_complete) it will also be associated with that channel and re-transmitted if necessary on restart."
],
"request": {
"required": [
diff --git a/doc/schemas/sendpsbt.json b/doc/schemas/sendpsbt.json
index fa1c07d..5409a3b 100644
--- a/doc/schemas/sendpsbt.json
+++ b/doc/schemas/sendpsbt.json
@@ -4,7 +4,7 @@
"rpc": "sendpsbt",
"title": "Command to finalize, extract and send a partially signed bitcoin transaction (PSBT).",
"description": [
- "The **sendpsbt** is a low-level RPC command which sends a fully-signed PSBT."
+ "The **sendpsbt** is a low-level RPC command which sends a fully-signed PSBT. If the PSBT is the same one promised by a channel (via fundchannel_complete) it will also be associated with that channel and re-transmitted if necessary on restart."
],
"request": {
"required": [
diff --git a/lightningd/channel_control.c b/lightningd/channel_control.c
index d82b190..627e436 100644
--- a/lightningd/channel_control.c
+++ b/lightningd/channel_control.c
@@ -2405,6 +2405,11 @@ static struct command_result *single_splice_signed(struct command *cmd,
SPLICE_INPUT_ERROR,
"Splice failed to convert to v2");
+ /* Update "funding" psbt now */
+ tal_free(channel->funding_psbt);
+ channel->funding_psbt = clone_psbt(channel, psbt);
+ wallet_channel_save(cmd->ld->wallet, channel);
+
msg = towire_channeld_splice_signed(tmpctx, psbt, sign_first);
subd_send_msg(channel->owner, take(msg));
if (success)
diff --git a/wallet/walletrpc.c b/wallet/walletrpc.c
index 6bb111e..31b5075 100644
--- a/wallet/walletrpc.c
+++ b/wallet/walletrpc.c
@@ -1,5 +1,6 @@
#include "config.h"
#include <bitcoin/script.h>
+#include <ccan/mem/mem.h>
#include <common/addr.h>
#include <common/base64.h>
#include <common/bech32.h>
@@ -1065,6 +1066,85 @@ static const struct json_command dev_finalizepsbt_command = {
};
AUTODATA(json_command, &dev_finalizepsbt_command);
+/* Yuck. */
+static const u8 *psbt_input_txid(const struct wally_psbt *psbt, size_t index)
+{
+ if (psbt->version == WALLY_PSBT_VERSION_0)
+ return psbt->tx->inputs[index].txhash;
+ return psbt->inputs[index].txhash;
+}
+
+static u32 psbt_input_index(const struct wally_psbt *psbt, size_t index)
+{
+ if (psbt->version == WALLY_PSBT_VERSION_0)
+ return psbt->tx->inputs[index].index;
+ return psbt->inputs[index].index;
+}
+
+static u32 psbt_input_sequence(const struct wally_psbt *psbt, size_t index)
+{
+ if (psbt->version == WALLY_PSBT_VERSION_0)
+ return psbt->tx->inputs[index].sequence;
+ return psbt->inputs[index].sequence;
+}
+
+static u64 psbt_output_amount(const struct wally_psbt *psbt, size_t index)
+{
+ if (psbt->version == WALLY_PSBT_VERSION_0)
+ return psbt->tx->outputs[index].satoshi;
+ return psbt->outputs[index].amount;
+}
+
+static size_t psbt_output_scriptlen(const struct wally_psbt *psbt, size_t index)
+{
+ if (psbt->version == WALLY_PSBT_VERSION_0)
+ return psbt->tx->outputs[index].script_len;
+ return psbt->outputs[index].script_len;
+}
+
+static const u8 *psbt_output_script(const struct wally_psbt *psbt, size_t index)
+{
+ if (psbt->version == WALLY_PSBT_VERSION_0)
+ return psbt->tx->outputs[index].script;
+ return psbt->outputs[index].script;
+}
+
+/* We consider two PSBTs *equivalent* if they have the same inputs and outputs */
+static bool psbt_equivalent(const struct wally_psbt *a,
+ const struct wally_psbt *b)
+{
+ if (a->num_inputs != b->num_inputs)
+ return false;
+ if (a->num_outputs != b->num_outputs)
+ return false;
+
+ for (size_t i = 0; i < a->num_inputs; i++) {
+ if (!memeq(psbt_input_txid(a, i), WALLY_TXHASH_LEN,
+ psbt_input_txid(b, i), WALLY_TXHASH_LEN))
+ return false;
+
+ if (psbt_input_index(a, i) != psbt_input_index(b, i))
+ return false;
+
+ if (psbt_input_sequence(a, i) != psbt_input_sequence(b, i))
+ return false;
+ }
+
+ for (size_t i = 0; i < a->num_outputs; i++) {
+ size_t a_scriptlen, b_scriptlen;
+
+ if (psbt_output_amount(a, i) != psbt_output_amount(b, i))
+ return false;
+ a_scriptlen = psbt_output_scriptlen(a, i);
+ b_scriptlen = psbt_output_scriptlen(b, i);
+ if (!memeq(psbt_output_script(a, i), a_scriptlen,
+ psbt_output_script(b, i), b_scriptlen))
+ return false;
+ }
+
+ return true;
+}
+
static struct command_result *json_sendpsbt(struct command *cmd,
const char *buffer,
const jsmntok_t *obj,
@@ -1075,6 +1155,8 @@ static struct command_result *json_sendpsbt(struct command *cmd,
struct wally_psbt *psbt;
struct lightningd *ld = cmd->ld;
u32 *reserve_blocks;
+ struct peer *p;
+ struct peer_node_id_map_iter it;
if (!param_check(cmd, buffer, params,
p_req("psbt", param_psbt, &psbt),
@@ -1109,6 +1191,30 @@ static struct command_result *json_sendpsbt(struct command *cmd,
if (command_check_only(cmd))
return command_check_done(cmd);
+ /* If this corresponds to one or more channels' PSBT, upgrade
+ * those to signed versions! */
+ for (p = peer_node_id_map_first(ld->peers, &it);
+ p;
+ p = peer_node_id_map_next(ld->peers, &it)) {
+ struct channel *c;
+
+ list_for_each(&p->channels, c, list) {
+ if (!c->funding_psbt)
+ continue;
+ if (psbt_is_finalized(c->funding_psbt))
+ continue;
+ if (!psbt_equivalent(psbt, c->funding_psbt))
+ continue;
+
+ /* Found one! */
+ tal_free(c->funding_psbt);
+ c->funding_psbt = clone_psbt(c, sending->psbt);
+ wallet_channel_save(ld->wallet, c);
+ log_info(c->log,
+ "Funding PSBT sent, and stored for rexmit");
+ }
+ }
+
for (size_t i = 0; i < tal_count(sending->utxos); i++) {
if (!wallet_reserve_utxo(ld->wallet, sending->utxos[i],
get_block_height(ld->topology),
Why this scored 32/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.