From 58f066a42228c1b6d411dc7a5fb3806af924feb6 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 11:15:36 +1000 Subject: [PATCH 01/14] hsmtool: add point-only dumpcommitments mode Allow callers to derive one public commitment point without printing the shaseed or any per-commitment secrets. Co-authored-by: Cursor --- doc/lightning-hsmtool.8.md | 7 +++++ tools/lightning-hsmtool.c | 62 +++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/doc/lightning-hsmtool.8.md b/doc/lightning-hsmtool.8.md index 6f128f1f2a35..930b0befd3a6 100644 --- a/doc/lightning-hsmtool.8.md +++ b/doc/lightning-hsmtool.8.md @@ -40,6 +40,13 @@ of the specified channel with the specified peer, identified by the channel database index. Specify *password* if the `hsm_secret_path` is encrypted. +**dumpcommitments** **--point-only** *node\_id* *channel\_dbid* *commitment\_number* *hsm\_secret\_path* + + Show only the per-commitment point of commitment *commitment\_number* +of the specified channel. +Unlike the form above, this prints no secrets, so the result is safe to +give to the peer of that channel. + **guesstoremote** *p2wpkh* *node\_id* *max\_channel\_dbid* *hsm\_secret\_path* \[*password*\] Brute-force the private key to our funds from a remote unilateral close diff --git a/tools/lightning-hsmtool.c b/tools/lightning-hsmtool.c index 95d0b2633557..835cbb06b50c 100644 --- a/tools/lightning-hsmtool.c +++ b/tools/lightning-hsmtool.c @@ -48,6 +48,8 @@ static void show_usage(const char *progname) printf(" - encrypt [LEGACY - binary format only]\n"); printf(" - dumpcommitments " "\n"); + printf(" - dumpcommitments --point-only " + " \n"); printf(" - guesstoremote " "\n"); printf(" - generatehsm \n"); @@ -348,6 +350,35 @@ static void dump_commitments_infos(struct node_id *node_id, u64 channel_id, } } +/* Unlike atoll, refuses anything which is not entirely a number. */ +static bool parse_u64(const char *arg, u64 *val) +{ + char *end; + + errno = 0; + *val = strtoull(arg, &end, 10); + return !errno && end != arg && *end == '\0' && arg[0] != '-'; +} + +static void print_commitment_point(const struct node_id *node_id, + u64 channel_id, + u64 commitment_number, + const char *hsm_secret_path) +{ + struct sha256 shaseed; + struct secret hsm_secret, channel_seed; + struct pubkey point; + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + + memcpy(hsm_secret.data, hsms->secret_data, sizeof(hsm_secret.data)); + get_channel_seed(&channel_seed, node_id, channel_id, &hsm_secret); + derive_shaseed(&channel_seed, &shaseed); + if (!per_commit_point(&shaseed, &point, commitment_number)) + errx(ERROR_KEYDERIV, "Could not derive point #%"PRIu64, + commitment_number); + printf("%s\n", fmt_pubkey(tmpctx, &point)); +} + static void guess_to_remote(const char *address, struct node_id *node_id, u64 tries, char *hsm_secret_path) { @@ -687,14 +718,31 @@ int main(int argc, char *argv[]) encrypt_hsm(argv[2]); } else if (streq(method, "dumpcommitments")) { - /* node_id channel_id depth hsm_secret */ - if (argc < 6) - show_usage(argv[0]); struct node_id node_id; - if (!node_id_from_hexstr(argv[2], strlen(argv[2]), &node_id)) - errx(ERROR_USAGE, "Bad node id"); - dump_commitments_infos(&node_id, atol(argv[3]), atol(argv[4]), - argv[5]); + + if (argc > 2 && streq(argv[2], "--point-only")) { + u64 dbid, commitment_number; + + if (argc != 7) + show_usage(argv[0]); + if (!node_id_from_hexstr(argv[3], strlen(argv[3]), &node_id)) + errx(ERROR_USAGE, "Bad node id"); + /* A mistyped number must not print the wrong point. */ + if (!parse_u64(argv[4], &dbid) || dbid == 0) + errx(ERROR_USAGE, "Bad channel dbid"); + if (!parse_u64(argv[5], &commitment_number)) + errx(ERROR_USAGE, "Bad commitment number"); + print_commitment_point(&node_id, dbid, + commitment_number, argv[6]); + } else { + /* node_id channel_id depth hsm_secret */ + if (argc < 6) + show_usage(argv[0]); + if (!node_id_from_hexstr(argv[2], strlen(argv[2]), &node_id)) + errx(ERROR_USAGE, "Bad node id"); + dump_commitments_infos(&node_id, atol(argv[3]), + atol(argv[4]), argv[5]); + } } else if (streq(method, "guesstoremote")) { /* address node_id depth hsm_secret */ From d27fb654ee2082122be6bdc4991aed77a2cde749 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 11:25:16 +1000 Subject: [PATCH 02/14] hsm: share channel seed derivation Move the historical channel-seed calculation into common/hsm_secret and reuse it from hsmd, checkchannels and lightning-hsmtool. Co-authored-by: Cursor --- common/hsm_secret.c | 22 ++++++++++++++++++++++ common/hsm_secret.h | 8 ++++++++ devtools/checkchannels.c | 38 ++------------------------------------ hsmd/libhsmd.c | 38 +------------------------------------- tools/lightning-hsmtool.c | 16 +--------------- 5 files changed, 34 insertions(+), 88 deletions(-) diff --git a/common/hsm_secret.c b/common/hsm_secret.c index 253553973cc8..9f3fff801b6a 100644 --- a/common/hsm_secret.c +++ b/common/hsm_secret.c @@ -1,11 +1,13 @@ #include "config.h" #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -28,6 +30,26 @@ #define PASSPHRASE_HASH_LEN 32 #define HSM_SECRET_PLAIN_SIZE 32 +void derive_channel_seed(struct secret *channel_seed, + const u8 hsm_secret[32], + const struct node_id *peer_id, + u64 dbid) +{ + struct secret channel_base; + u8 input[sizeof(peer_id->k) + sizeof(dbid)]; + + /* These labels and the native-endian dbid are historical and must + * never change: existing channel keys depend on them. */ + hkdf_sha256(&channel_base, sizeof(channel_base), NULL, 0, + hsm_secret, 32, "peer seed", strlen("peer seed")); + memcpy(input, peer_id->k, sizeof(peer_id->k)); + BUILD_ASSERT(sizeof(peer_id->k) == PUBKEY_CMPR_LEN); + memcpy(input + PUBKEY_CMPR_LEN, &dbid, sizeof(dbid)); + hkdf_sha256(channel_seed, sizeof(*channel_seed), + input, sizeof(input), &channel_base, sizeof(channel_base), + "per-peer seed", strlen("per-peer seed")); +} + /* Helper function to validate a mnemonic string */ enum hsm_secret_error validate_mnemonic(const char *mnemonic) { diff --git a/common/hsm_secret.h b/common/hsm_secret.h index 1f6314e63248..427cf24014ac 100644 --- a/common/hsm_secret.h +++ b/common/hsm_secret.h @@ -8,6 +8,14 @@ #include #include +struct node_id; + +/* Derive the seed for one channel from the first 32 bytes of hsm_secret. */ +void derive_channel_seed(struct secret *channel_seed, + const u8 hsm_secret[32], + const struct node_id *peer_id, + u64 dbid); + /* Length constants for encrypted HSM secret files */ #define HS_HEADER_LEN crypto_secretstream_xchacha20poly1305_HEADERBYTES #define HS_CIPHERTEXT_LEN \ diff --git a/devtools/checkchannels.c b/devtools/checkchannels.c index 1ca9babcf2c4..af66eeb40714 100644 --- a/devtools/checkchannels.c +++ b/devtools/checkchannels.c @@ -9,51 +9,17 @@ #include #include #include +#include #include #include #include #include -static void hsm_channel_secret_base(struct secret *channel_seed_base, - const struct secret *hsm_secret) -{ - hkdf_sha256(channel_seed_base, sizeof(struct secret), NULL, 0, - hsm_secret, sizeof(*hsm_secret), - /*~ Initially, we didn't support multiple channels per - * peer at all: a channel had to be completely forgotten - * before another could exist. That was slightly relaxed, - * but the phrase "peer seed" is wired into the seed - * generation here, so we need to keep it that way for - * existing clients, rather than using "channel seed". */ - "peer seed", strlen("peer seed")); -} - static void get_channel_seed(const struct secret *hsm_secret, const struct node_id *peer_id, u64 dbid, struct secret *channel_seed) { - struct secret channel_base; - u8 input[sizeof(peer_id->k) + sizeof(dbid)]; - /*~ Again, "per-peer" should be "per-channel", but Hysterical Raisins */ - const char *info = "per-peer seed"; - - /*~ We use the DER encoding of the pubkey, because it's platform - * independent. Since the dbid is unique, however, it's completely - * unnecessary, but again, existing users can't be broken. */ - /* FIXME: lnd has a nicer BIP32 method for deriving secrets which we - * should migrate to. */ - hsm_channel_secret_base(&channel_base, hsm_secret); - memcpy(input, peer_id->k, sizeof(peer_id->k)); - BUILD_ASSERT(sizeof(peer_id->k) == PUBKEY_CMPR_LEN); - /*~ For all that talk about platform-independence, note that this - * field is endian-dependent! But let's face it, little-endian won. - * In related news, we don't support EBCDIC or middle-endian. */ - memcpy(input + PUBKEY_CMPR_LEN, &dbid, sizeof(dbid)); - - hkdf_sha256(channel_seed, sizeof(*channel_seed), - input, sizeof(input), - &channel_base, sizeof(channel_base), - info, strlen(info)); + derive_channel_seed(channel_seed, hsm_secret->data, peer_id, dbid); } struct keys { diff --git a/hsmd/libhsmd.c b/hsmd/libhsmd.c index 9e48a362a4db..a5cfa620ab96 100644 --- a/hsmd/libhsmd.c +++ b/hsmd/libhsmd.c @@ -293,21 +293,6 @@ static void node_schnorrkey(secp256k1_keypair *node_keypair) "Failed to derive keypair"); } -/*~ This secret is the basis for all per-channel secrets: the per-channel seeds - * will be generated by mixing in the dbid and the peer node_id. */ -static void hsm_channel_secret_base(struct secret *channel_seed_base) -{ - hkdf_sha256(channel_seed_base, sizeof(struct secret), NULL, 0, - secretstuff.bip32_seed, 32, /* Use first 32 bytes */ - /*~ Initially, we didn't support multiple channels per - * peer at all: a channel had to be completely forgotten - * before another could exist. That was slightly relaxed, - * but the phrase "peer seed" is wired into the seed - * generation here, so we need to keep it that way for - * existing clients, rather than using "channel seed". */ - "peer seed", strlen("peer seed")); -} - /* This will derive pseudorandom secret Key from a derived key */ static u8 *handle_derive_secret(struct hsmd_client *c, const u8 *msg_in) { @@ -328,28 +313,7 @@ static u8 *handle_derive_secret(struct hsmd_client *c, const u8 *msg_in) static void get_channel_seed(const struct node_id *peer_id, u64 dbid, struct secret *channel_seed) { - struct secret channel_base; - u8 input[sizeof(peer_id->k) + sizeof(dbid)]; - /*~ Again, "per-peer" should be "per-channel", but Hysterical Raisins */ - const char *info = "per-peer seed"; - - /*~ We use the DER encoding of the pubkey, because it's platform - * independent. Since the dbid is unique, however, it's completely - * unnecessary, but again, existing users can't be broken. */ - /* FIXME: lnd has a nicer BIP32 method for deriving secrets which we - * should migrate to. */ - hsm_channel_secret_base(&channel_base); - memcpy(input, peer_id->k, sizeof(peer_id->k)); - BUILD_ASSERT(sizeof(peer_id->k) == PUBKEY_CMPR_LEN); - /*~ For all that talk about platform-independence, note that this - * field is endian-dependent! But let's face it, little-endian won. - * In related news, we don't support EBCDIC or middle-endian. */ - memcpy(input + PUBKEY_CMPR_LEN, &dbid, sizeof(dbid)); - - hkdf_sha256(channel_seed, sizeof(*channel_seed), - input, sizeof(input), - &channel_base, sizeof(channel_base), - info, strlen(info)); + derive_channel_seed(channel_seed, secretstuff.bip32_seed, peer_id, dbid); } /* ~This stub implementation is overriden by fully validating signers diff --git a/tools/lightning-hsmtool.c b/tools/lightning-hsmtool.c index 835cbb06b50c..6eba5185ac30 100644 --- a/tools/lightning-hsmtool.c +++ b/tools/lightning-hsmtool.c @@ -254,21 +254,7 @@ static void encrypt_hsm(const char *hsm_secret_path) static void get_channel_seed(struct secret *channel_seed, const struct node_id *peer_id, u64 dbid, struct secret *hsm_secret) { - struct secret channel_base; - u8 input[sizeof(peer_id->k) + sizeof(dbid)]; - const char *info = "per-peer seed"; - - hkdf_sha256(&channel_base, sizeof(struct secret), NULL, 0, - hsm_secret, sizeof(*hsm_secret), - "peer seed", strlen("peer seed")); - memcpy(input, peer_id->k, sizeof(peer_id->k)); - BUILD_ASSERT(sizeof(peer_id->k) == PUBKEY_CMPR_LEN); - memcpy(input + PUBKEY_CMPR_LEN, &dbid, sizeof(dbid)); - - hkdf_sha256(channel_seed, sizeof(*channel_seed), - input, sizeof(input), - &channel_base, sizeof(channel_base), - info, strlen(info)); + derive_channel_seed(channel_seed, hsm_secret->data, peer_id, dbid); } static void print_secret(const char *hsm_secret_path, const char *id, bool must_be_oldstyle) From bac5e8c242c25b153225226b5b958e18ad8c6d40 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 11:37:42 +1000 Subject: [PATCH 03/14] hsmtool: make hsm secret loading reusable The recovery tool is long, so it is best kept in its own file. Expose load_hsm_secret in a header so it can be shared with the recovery tool. Co-authored-by: Cursor --- tools/Makefile | 2 +- tools/lightning-hsmtool.c | 35 +++++++++++++++++++++++------------ tools/lightning-hsmtool.h | 11 +++++++++++ 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 tools/lightning-hsmtool.h diff --git a/tools/Makefile b/tools/Makefile index 2d86221df962..26cd7484df44 100644 --- a/tools/Makefile +++ b/tools/Makefile @@ -5,7 +5,7 @@ TOOLS_OBJ := $(TOOLS_SRC:.c=.o) # Make sure these depend on everything. ALL_C_SOURCES += $(TOOLS_SRC) -ALL_C_HEADERS += +ALL_C_HEADERS += tools/lightning-hsmtool.h ALL_PROGRAMS += $(TOOLS) # We force make to relink this every time, to detect version changes. diff --git a/tools/lightning-hsmtool.c b/tools/lightning-hsmtool.c index 6eba5185ac30..929d551885ce 100644 --- a/tools/lightning-hsmtool.c +++ b/tools/lightning-hsmtool.c @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -87,7 +88,8 @@ static bool ensure_hsm_secret_exists(int fd, const char *path) return true; } /* Load hsm_secret using the unified interface */ -static struct hsm_secret *load_hsm_secret(const tal_t *ctx, const char *hsm_secret_path) +struct hsm_secret *hsmtool_load_hsm_secret(const tal_t *ctx, + const char *hsm_secret_path) { u8 *contents = grab_file_raw(tmpctx, hsm_secret_path); const char *passphrase = NULL; @@ -135,7 +137,7 @@ static void decrypt_hsm(const char *hsm_secret_path) } /* Load the hsm_secret */ - hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsms = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); dir = path_dirname(NULL, hsm_secret_path); backup = path_join(dir, dir, "hsm_secret.backup"); @@ -192,7 +194,7 @@ static void encrypt_hsm(const char *hsm_secret_path) } /* Load the hsm_secret */ - hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsms = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); printf("Enter hsm_secret password:\n"); fflush(stdout); @@ -262,7 +264,8 @@ static void print_secret(const char *hsm_secret_path, const char *id, bool must_ struct secret hsm_secret; char *bip93; const char *err; - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); switch (hsms->type) { case HSM_SECRET_ENCRYPTED: @@ -316,7 +319,8 @@ static void dump_commitments_infos(struct node_id *node_id, u64 channel_id, struct sha256 shaseed; struct secret hsm_secret, channel_seed, per_commitment_secret; struct pubkey per_commitment_point; - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); /* Extract first 32 bytes for legacy compatibility */ memcpy(hsm_secret.data, hsms->secret_data, 32); get_channel_seed(&channel_seed, node_id, channel_id, &hsm_secret); @@ -354,7 +358,8 @@ static void print_commitment_point(const struct node_id *node_id, struct sha256 shaseed; struct secret hsm_secret, channel_seed; struct pubkey point; - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); memcpy(hsm_secret.data, hsms->secret_data, sizeof(hsm_secret.data)); get_channel_seed(&channel_seed, node_id, channel_id, &hsm_secret); @@ -382,7 +387,8 @@ static void guess_to_remote(const char *address, struct node_id *node_id, if (segwit_addr_decode(&witver, goal_pubkeyhash, &witlen, hrp, address) != 1) errx(ERROR_USAGE, "Wrong bech32 address"); - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); memcpy(hsm_secret.data, hsms->secret_data, 32); for (u64 dbid = 1; dbid < tries ; dbid++) { @@ -476,7 +482,8 @@ static void derive_to_remote(const struct unilateral_close_info *info, const cha secp256k1_ctx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY | SECP256K1_CONTEXT_SIGN); - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); /* Copy first 32 bytes of secret_data to the local secret struct */ memcpy(hsm_secret.data, hsms->secret_data, sizeof(hsm_secret.data)); get_channel_seed(&channel_seed, &info->peer_id, info->channel_id, &hsm_secret); @@ -502,7 +509,8 @@ static void dumponchaindescriptors(const char *hsm_secret_path, struct ext_key master_extkey; char *enc_xkey, *descriptor; struct descriptor_checksum checksum; - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); const char *path; /* For BIP 86, we derive from subkey: m/86'/0'/0' */ @@ -591,7 +599,8 @@ static void check_hsm(const char *hsm_secret_path) enum hsm_secret_error err; /* Load the hsm_secret (handles decryption automatically if needed) */ - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); /* Extract first 32 bytes for legacy compatibility */ memcpy(file_secret.data, hsms->secret_data, 32); @@ -631,7 +640,8 @@ static void make_rune(const char *hsm_secret_path) { struct secret hsm_secret, derived_secret, rune_secret; struct rune *master_rune, *rune; - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); /* Extract first 32 bytes for legacy compatibility */ memcpy(hsm_secret.data, hsms->secret_data, 32); @@ -659,7 +669,8 @@ static void print_node_id(const char *hsm_secret_path) struct secret hsm_secret; struct privkey node_privkey; struct pubkey node_id; - struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); /* Extract first 32 bytes for legacy compatibility */ memcpy(hsm_secret.data, hsms->secret_data, 32); diff --git a/tools/lightning-hsmtool.h b/tools/lightning-hsmtool.h new file mode 100644 index 000000000000..75832a2e52cd --- /dev/null +++ b/tools/lightning-hsmtool.h @@ -0,0 +1,11 @@ +#ifndef LIGHTNING_TOOLS_LIGHTNING_HSMTOOL_H +#define LIGHTNING_TOOLS_LIGHTNING_HSMTOOL_H +#include "config.h" +#include + +struct hsm_secret; + +struct hsm_secret *hsmtool_load_hsm_secret(const tal_t *ctx, + const char *hsm_secret_path); + +#endif /* LIGHTNING_TOOLS_LIGHTNING_HSMTOOL_H */ From a8418b98057e6a35353c2624ca03d6f51684d49b Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 15:31:37 +1000 Subject: [PATCH 04/14] hsmtool: derive keys for HTLC recovery Derive the channel basepoints and commitment-specific keys needed to recover HTLC outputs without contacting lightningd or hsmd. Co-authored-by: Cursor --- tools/Makefile | 6 ++-- tools/hsmtool-recoverhtlc.c | 70 +++++++++++++++++++++++++++++++++++++ tools/hsmtool-recoverhtlc.h | 27 ++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tools/hsmtool-recoverhtlc.c create mode 100644 tools/hsmtool-recoverhtlc.h diff --git a/tools/Makefile b/tools/Makefile index 26cd7484df44..daff4fce3890 100644 --- a/tools/Makefile +++ b/tools/Makefile @@ -1,11 +1,11 @@ #! /usr/bin/make TOOLS := tools/lightning-hsmtool tools/lightning-downgrade -TOOLS_SRC := $(TOOLS:=.c) +TOOLS_SRC := $(TOOLS:=.c) tools/hsmtool-recoverhtlc.c TOOLS_OBJ := $(TOOLS_SRC:.c=.o) # Make sure these depend on everything. ALL_C_SOURCES += $(TOOLS_SRC) -ALL_C_HEADERS += tools/lightning-hsmtool.h +ALL_C_HEADERS += tools/hsmtool-recoverhtlc.h tools/lightning-hsmtool.h ALL_PROGRAMS += $(TOOLS) # We force make to relink this every time, to detect version changes. @@ -16,7 +16,7 @@ tools/headerversions: $(FORCE) tools/headerversions.o libccan.a $(TOOLS): libcommon.a tools/headerversions.o: ccan/config.h -tools/lightning-hsmtool: tools/lightning-hsmtool.o +tools/lightning-hsmtool: tools/lightning-hsmtool.o tools/hsmtool-recoverhtlc.o tools/lightning-downgrade.o: CFLAGS:=$(CFLAGS) -DCLN_PREV_VERSION=$(CLN_PREV_VERSION) tools/lightning-downgrade: \ diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c new file mode 100644 index 000000000000..15c418ca2135 --- /dev/null +++ b/tools/hsmtool-recoverhtlc.c @@ -0,0 +1,70 @@ +#include "config.h" +#include +#include +#include +#include +#include +#include +#include + +static bool derive_channel_basepoints( + const struct node_id *peer_id, + u64 channel_dbid, + const char *hsm_secret_path, + struct recoverhtlc_keys *keys, + struct secret *htlc_basepoint_secret) +{ + struct secret hsm_secret, channel_seed; + struct hsm_secret *hsms + = hsmtool_load_hsm_secret(tmpctx, hsm_secret_path); + + /* Channel derivation always uses the first 32 bytes. */ + memcpy(hsm_secret.data, hsms->secret_data, sizeof(hsm_secret.data)); + derive_channel_seed(&channel_seed, hsm_secret.data, + peer_id, channel_dbid); + + if (!derive_htlc_basepoint(&channel_seed, &keys->our_htlc_basepoint, + htlc_basepoint_secret)) + return false; + return derive_revocation_basepoint(&channel_seed, + &keys->our_revocation_basepoint, + NULL); +} + +/* Derive keys to spend our HTLC outputs on the peer's commitment. */ +bool hsmtool_recoverhtlc_derive_keys( + const struct node_id *peer_id, + u64 channel_dbid, + const struct pubkey *commitment_point, + const struct pubkey *remote_htlc_basepoint, + const char *hsm_secret_path, + struct recoverhtlc_keys *keys) +{ + struct secret htlc_basepoint_secret; + + if (!derive_channel_basepoints(peer_id, channel_dbid, hsm_secret_path, + keys, &htlc_basepoint_secret)) + return false; + + /* BOLT #3: + * + * pubkey = basepoint + * + SHA256(per_commitment_point || basepoint) * G + */ + if (!derive_simple_privkey(&htlc_basepoint_secret, + &keys->our_htlc_basepoint, + commitment_point, + &keys->our_htlc_privkey) + || !derive_simple_key(&keys->our_htlc_basepoint, + commitment_point, + &keys->our_htlc_key) + || !derive_simple_key(remote_htlc_basepoint, + commitment_point, + &keys->their_htlc_key)) + return false; + + /* Peer commitment: our basepoint, their commitment point. */ + return derive_revocation_key(&keys->our_revocation_basepoint, + commitment_point, + &keys->revocation_key); +} diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h new file mode 100644 index 000000000000..74a4ce85e180 --- /dev/null +++ b/tools/hsmtool-recoverhtlc.h @@ -0,0 +1,27 @@ +#ifndef LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H +#define LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H +#include "config.h" +#include +#include + +struct node_id; + +/* Key names are from the commitment owner's perspective. */ +struct recoverhtlc_keys { + struct pubkey our_htlc_basepoint; + struct pubkey our_revocation_basepoint; + struct privkey our_htlc_privkey; + struct pubkey our_htlc_key; /* remote_htlcpubkey */ + struct pubkey their_htlc_key; /* local_htlcpubkey */ + struct pubkey revocation_key; +}; + +bool hsmtool_recoverhtlc_derive_keys( + const struct node_id *peer_id, + u64 channel_dbid, + const struct pubkey *commitment_point, + const struct pubkey *remote_htlc_basepoint, + const char *hsm_secret_path, + struct recoverhtlc_keys *keys); + +#endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From 577fc515314e08d4dd81a64e11c240f037c7a8ed Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 15:40:32 +1000 Subject: [PATCH 05/14] hsmtool: reconstruct HTLC witness scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build offered and received HTLC scripts for the peer’s commitment, including anchor channels and CLTV expiry. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 36 ++++++++++++++++++++++++++++++++++++ tools/hsmtool-recoverhtlc.h | 15 +++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 15c418ca2135..3c5fda40a4ae 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -1,7 +1,11 @@ #include "config.h" +#include +#include #include #include +#include #include +#include #include #include #include @@ -68,3 +72,35 @@ bool hsmtool_recoverhtlc_derive_keys( commitment_point, &keys->revocation_key); } + +/* Rebuild an HTLC witness script from the peer's commitment. */ +u8 *hsmtool_recoverhtlc_wscript( + const tal_t *ctx, + enum recoverhtlc_kind kind, + bool anchors, + const struct recoverhtlc_keys *keys, + const struct ripemd160 *payment_ripemd, + u32 cltv_expiry) +{ + struct abs_locktime expiry; + struct keyset keyset = { + .self_revocation_key = keys->revocation_key, + .self_htlc_key = keys->their_htlc_key, + .other_htlc_key = keys->our_htlc_key, + }; + + switch (kind) { + /* The peer offered it, so this is their offered HTLC script. */ + case HTLC_THEY_OFFERED: + return htlc_offered_wscript(ctx, payment_ripemd, &keyset, + anchors, false); + + /* We offered it, so this is the peer's received HTLC script. */ + case HTLC_WE_OFFERED: + if (!blocks_to_abs_locktime(cltv_expiry, &expiry)) + return NULL; + return htlc_received_wscript(ctx, payment_ripemd, &expiry, + &keyset, anchors, false); + } + abort(); +} diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index 74a4ce85e180..65eae380ed72 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -3,8 +3,15 @@ #include "config.h" #include #include +#include struct node_id; +struct ripemd160; + +enum recoverhtlc_kind { + HTLC_THEY_OFFERED, + HTLC_WE_OFFERED, +}; /* Key names are from the commitment owner's perspective. */ struct recoverhtlc_keys { @@ -24,4 +31,12 @@ bool hsmtool_recoverhtlc_derive_keys( const char *hsm_secret_path, struct recoverhtlc_keys *keys); +u8 *hsmtool_recoverhtlc_wscript( + const tal_t *ctx, + enum recoverhtlc_kind kind, + bool anchors, + const struct recoverhtlc_keys *keys, + const struct ripemd160 *payment_ripemd, + u32 cltv_expiry); + #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From 2ae9d0ca8e4c72d7adb18b58540cf5b330ab4f04 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 15:51:14 +1000 Subject: [PATCH 06/14] hsmtool: match HTLC commitment outputs Match reconstructed P2WSH scripts against commitment outputs and retain every matching index so identical MPP parts can be recovered together. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 31 +++++++++++++++++++++++++++++++ tools/hsmtool-recoverhtlc.h | 15 +++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 3c5fda40a4ae..83e716d7364f 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -1,6 +1,7 @@ #include "config.h" #include #include +#include #include #include #include @@ -104,3 +105,33 @@ u8 *hsmtool_recoverhtlc_wscript( } abort(); } + +/* Collect every output with this witness script, including identical MPPs. */ +struct recoverhtlc_match *hsmtool_recoverhtlc_match_script( + const tal_t *ctx, + const struct bitcoin_tx *tx, + enum recoverhtlc_kind kind, + bool anchors, + const u8 *wscript) +{ + struct recoverhtlc_match *match = tal(ctx, struct recoverhtlc_match); + const u8 *scriptpubkey = scriptpubkey_p2wsh(tmpctx, wscript); + + match->kind = kind; + match->anchors = anchors; + match->wscript = tal_dup_arr(match, u8, wscript, tal_bytelen(wscript), 0); + match->outnums = tal_arr(match, size_t, 0); + + for (size_t i = 0; i < tx->wtx->num_outputs; i++) { + const struct wally_tx_output *output = &tx->wtx->outputs[i]; + + if (!memeq(output->script, output->script_len, + scriptpubkey, tal_bytelen(scriptpubkey))) + continue; + tal_arr_expand(&match->outnums, i); + } + + if (tal_count(match->outnums) == 0) + return tal_free(match); + return match; +} diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index 65eae380ed72..5c6badd6a3f8 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -2,6 +2,7 @@ #define LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H #include "config.h" #include +#include #include #include @@ -13,6 +14,13 @@ enum recoverhtlc_kind { HTLC_WE_OFFERED, }; +struct recoverhtlc_match { + enum recoverhtlc_kind kind; + bool anchors; + u8 *wscript; + size_t *outnums; +}; + /* Key names are from the commitment owner's perspective. */ struct recoverhtlc_keys { struct pubkey our_htlc_basepoint; @@ -39,4 +47,11 @@ u8 *hsmtool_recoverhtlc_wscript( const struct ripemd160 *payment_ripemd, u32 cltv_expiry); +struct recoverhtlc_match *hsmtool_recoverhtlc_match_script( + const tal_t *ctx, + const struct bitcoin_tx *tx, + enum recoverhtlc_kind kind, + bool anchors, + const u8 *wscript); + #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From a3268e46316bc537ee5217718f0ebdec9e727e9f Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 22:29:32 +1000 Subject: [PATCH 07/14] hsmtool: find recoverable HTLC outputs Build both anchor variants of the requested HTLC script and use the commitment transaction to determine which one contains the outputs. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 37 ++++++++++++++++++++++++++++++++++++- tools/hsmtool-recoverhtlc.h | 15 ++++++++------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 83e716d7364f..50d17e7493ad 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -1,6 +1,7 @@ #include "config.h" #include #include +#include #include #include #include @@ -75,7 +76,7 @@ bool hsmtool_recoverhtlc_derive_keys( } /* Rebuild an HTLC witness script from the peer's commitment. */ -u8 *hsmtool_recoverhtlc_wscript( +static u8 *recovery_wscript( const tal_t *ctx, enum recoverhtlc_kind kind, bool anchors, @@ -115,6 +116,7 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_match_script( const u8 *wscript) { struct recoverhtlc_match *match = tal(ctx, struct recoverhtlc_match); + /* Commitment outputs contain the P2WSH hash, not the witness script. */ const u8 *scriptpubkey = scriptpubkey_p2wsh(tmpctx, wscript); match->kind = kind; @@ -135,3 +137,36 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_match_script( return tal_free(match); return match; } + +/* Find the requested HTLC without requiring its channel type. */ +struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( + const tal_t *ctx, + const struct bitcoin_tx *tx, + enum recoverhtlc_kind kind, + const struct recoverhtlc_keys *keys, + const struct sha256 *payment_hash, + u32 cltv_expiry) +{ + struct ripemd160 payment_ripemd; + + ripemd160(&payment_ripemd, + payment_hash->u.u8, sizeof(payment_hash->u.u8)); + + /* Anchors append `1 CSV DROP`, changing the P2WSH hash. Try both + * forms; only the negotiated form can match a commitment output. */ + for (int anchors = 0; anchors <= 1; anchors++) { + struct recoverhtlc_match *match; + const u8 *wscript = recovery_wscript( + tmpctx, kind, anchors, keys, + &payment_ripemd, cltv_expiry); + + if (!wscript) + return NULL; + match = hsmtool_recoverhtlc_match_script( + ctx, tx, kind, anchors, wscript); + /* A match also records which anchor form was used. */ + if (match) + return match; + } + return NULL; +} diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index 5c6badd6a3f8..b7d363df8e7a 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -3,6 +3,7 @@ #include "config.h" #include #include +#include #include #include @@ -39,19 +40,19 @@ bool hsmtool_recoverhtlc_derive_keys( const char *hsm_secret_path, struct recoverhtlc_keys *keys); -u8 *hsmtool_recoverhtlc_wscript( +struct recoverhtlc_match *hsmtool_recoverhtlc_match_script( const tal_t *ctx, + const struct bitcoin_tx *tx, enum recoverhtlc_kind kind, bool anchors, - const struct recoverhtlc_keys *keys, - const struct ripemd160 *payment_ripemd, - u32 cltv_expiry); + const u8 *wscript); -struct recoverhtlc_match *hsmtool_recoverhtlc_match_script( +struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( const tal_t *ctx, const struct bitcoin_tx *tx, enum recoverhtlc_kind kind, - bool anchors, - const u8 *wscript); + const struct recoverhtlc_keys *keys, + const struct sha256 *payment_hash, + u32 cltv_expiry); #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From 4b7a7e0af46da97a2907dfeb65aa8e9358ae6ef5 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Tue, 10 Nov 2026 22:44:15 +1000 Subject: [PATCH 08/14] hsmtool: sign HTLC recovery transactions Build a single transaction spending every matched HTLC output and sign each input with the appropriate timeout or preimage witness. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 89 +++++++++++++++++++++++++++++++++++++ tools/hsmtool-recoverhtlc.h | 12 +++++ 2 files changed, 101 insertions(+) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 50d17e7493ad..6a598ed33fa9 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -1,6 +1,8 @@ #include "config.h" +#include #include #include +#include #include #include #include @@ -170,3 +172,90 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( } return NULL; } + +static struct bitcoin_tx *new_recovery_tx( + const tal_t *ctx, + const struct bitcoin_tx *commitment_tx, + const struct recoverhtlc_match *match, + const u8 *destination_script, + struct amount_sat output_amount, + u32 cltv_expiry) +{ + struct bitcoin_txid commitment_txid; + struct bitcoin_tx *tx; + size_t num_inputs = tal_count(match->outnums); + + /* Our timeout path requires nLockTime; the preimage path does not. */ + tx = bitcoin_tx(ctx, chainparams, num_inputs, 1, + match->kind == HTLC_WE_OFFERED ? cltv_expiry : 0); + bitcoin_txid(commitment_tx, &commitment_txid); + + for (size_t i = 0; i < num_inputs; i++) { + struct bitcoin_outpoint outpoint = { + .txid = commitment_txid, + .n = match->outnums[i], + }; + struct amount_sat amount = bitcoin_tx_output_get_amount_sat( + commitment_tx, match->outnums[i]); + + /* Segwit signatures commit to each input's amount and script. */ + /* Anchor HTLC spends must wait one confirmation. */ + bitcoin_tx_add_input(tx, &outpoint, match->anchors ? 1 : 0, + NULL, amount, NULL, match->wscript); + } + bitcoin_tx_add_output(tx, destination_script, NULL, output_amount); + return tx; +} + +static bool sign_recovery_tx(struct bitcoin_tx *tx, + const struct recoverhtlc_match *match, + const struct recoverhtlc_keys *keys, + const struct preimage *preimage) +{ + /* SIGHASH_ALL commits every input to the single destination output. */ + for (size_t i = 0; i < tal_count(match->outnums); i++) { + struct bitcoin_signature signature; + + sign_tx_input(tx, i, NULL, match->wscript, + &keys->our_htlc_privkey, &keys->our_htlc_key, + SIGHASH_ALL, &signature); + if (!check_tx_sig(tx, i, NULL, match->wscript, + &keys->our_htlc_key, &signature)) + return false; + /* Witness: signature, optional preimage, then the script. */ + bitcoin_tx_input_set_witness( + tx, i, + take(bitcoin_witness_sig_and_element( + tx, &signature, + preimage ? preimage->r : NULL, + preimage ? sizeof(preimage->r) : 0, + match->wscript))); + } + return true; +} + +/* Build and sign one transaction spending every matched HTLC output. */ +struct bitcoin_tx *hsmtool_recoverhtlc_sign( + const tal_t *ctx, + const struct bitcoin_tx *commitment_tx, + const struct recoverhtlc_match *match, + const struct recoverhtlc_keys *keys, + const u8 *destination_script, + struct amount_sat output_amount, + u32 cltv_expiry, + const struct preimage *preimage) +{ + struct bitcoin_tx *tx; + + /* Peer-offered HTLCs need a preimage; our HTLCs use timeout. */ + if (match->kind == HTLC_THEY_OFFERED && !preimage) + return NULL; + if (match->kind == HTLC_WE_OFFERED && preimage) + return NULL; + + tx = new_recovery_tx(ctx, commitment_tx, match, destination_script, + output_amount, cltv_expiry); + if (!sign_recovery_tx(tx, match, keys, preimage)) + return tal_free(tx); + return tx; +} diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index b7d363df8e7a..78ad5d4873a1 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -1,11 +1,13 @@ #ifndef LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H #define LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H #include "config.h" +#include #include #include #include #include #include +#include struct node_id; struct ripemd160; @@ -55,4 +57,14 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( const struct sha256 *payment_hash, u32 cltv_expiry); +struct bitcoin_tx *hsmtool_recoverhtlc_sign( + const tal_t *ctx, + const struct bitcoin_tx *commitment_tx, + const struct recoverhtlc_match *match, + const struct recoverhtlc_keys *keys, + const u8 *destination_script, + struct amount_sat output_amount, + u32 cltv_expiry, + const struct preimage *preimage); + #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From 4305d6b674bd68c51fa9e01b723663298ad6170d Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Wed, 11 Nov 2026 10:22:07 +1000 Subject: [PATCH 09/14] hsmtool: calculate HTLC recovery fees Sum the matched HTLC outputs and calculate the fee and destination amount from the estimated signed transaction weight. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 57 +++++++++++++++++++++++++++++++++---- tools/hsmtool-recoverhtlc.h | 11 +++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 6a598ed33fa9..61d7fd3c32c3 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -1,8 +1,10 @@ #include "config.h" #include +#include #include #include #include +#include #include #include #include @@ -173,12 +175,42 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( return NULL; } +/* Witness stack count, signature, selector, and witness script. */ +static size_t recovery_witness_weight(const struct recoverhtlc_match *match) +{ + size_t wscript_len = tal_bytelen(match->wscript); + + return 1 + bitcoin_tx_input_sig_weight() + + (match->kind == HTLC_THEY_OFFERED ? 1 + 32 : 1) + + varint_size(wscript_len) + wscript_len; +} + +/* Deduct the signed transaction's fee from its single output. */ +static bool set_recovery_output_amount( + struct bitcoin_tx *tx, + const struct recoverhtlc_match *match, + u32 feerate_perkw, + struct recoverhtlc_amounts *amounts) +{ + /* The transaction is unsigned, so add each input's future witness. */ + size_t weight = bitcoin_tx_weight(tx) + + tal_count(match->outnums) * recovery_witness_weight(match); + + amounts->fee = amount_tx_fee(feerate_perkw, weight); + /* Refuse a recovery whose fee consumes all of the HTLC value. */ + if (!amount_sat_sub(&amounts->output, amounts->input, amounts->fee)) + return false; + bitcoin_tx_output_set_amount(tx, 0, amounts->output); + return true; +} + static struct bitcoin_tx *new_recovery_tx( const tal_t *ctx, const struct bitcoin_tx *commitment_tx, const struct recoverhtlc_match *match, const u8 *destination_script, - struct amount_sat output_amount, + u32 feerate_perkw, + struct recoverhtlc_amounts *amounts, u32 cltv_expiry) { struct bitcoin_txid commitment_txid; @@ -189,6 +221,7 @@ static struct bitcoin_tx *new_recovery_tx( tx = bitcoin_tx(ctx, chainparams, num_inputs, 1, match->kind == HTLC_WE_OFFERED ? cltv_expiry : 0); bitcoin_txid(commitment_tx, &commitment_txid); + amounts->input = AMOUNT_SAT(0); for (size_t i = 0; i < num_inputs; i++) { struct bitcoin_outpoint outpoint = { @@ -197,13 +230,22 @@ static struct bitcoin_tx *new_recovery_tx( }; struct amount_sat amount = bitcoin_tx_output_get_amount_sat( commitment_tx, match->outnums[i]); + struct amount_sat total; + + if (!amount_sat_add(&total, amounts->input, amount)) + return tal_free(tx); + amounts->input = total; /* Segwit signatures commit to each input's amount and script. */ /* Anchor HTLC spends must wait one confirmation. */ bitcoin_tx_add_input(tx, &outpoint, match->anchors ? 1 : 0, NULL, amount, NULL, match->wscript); } - bitcoin_tx_add_output(tx, destination_script, NULL, output_amount); + + /* The complete transaction shape is needed to calculate its fee. */ + bitcoin_tx_add_output(tx, destination_script, NULL, amounts->input); + if (!set_recovery_output_amount(tx, match, feerate_perkw, amounts)) + return tal_free(tx); return tx; } @@ -234,16 +276,17 @@ static bool sign_recovery_tx(struct bitcoin_tx *tx, return true; } -/* Build and sign one transaction spending every matched HTLC output. */ +/* Calculate fees, then build and sign a transaction sweeping every match. */ struct bitcoin_tx *hsmtool_recoverhtlc_sign( const tal_t *ctx, const struct bitcoin_tx *commitment_tx, const struct recoverhtlc_match *match, const struct recoverhtlc_keys *keys, const u8 *destination_script, - struct amount_sat output_amount, + u32 feerate_perkw, u32 cltv_expiry, - const struct preimage *preimage) + const struct preimage *preimage, + struct recoverhtlc_amounts *amounts) { struct bitcoin_tx *tx; @@ -254,7 +297,9 @@ struct bitcoin_tx *hsmtool_recoverhtlc_sign( return NULL; tx = new_recovery_tx(ctx, commitment_tx, match, destination_script, - output_amount, cltv_expiry); + feerate_perkw, amounts, cltv_expiry); + if (!tx) + return NULL; if (!sign_recovery_tx(tx, match, keys, preimage)) return tal_free(tx); return tx; diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index 78ad5d4873a1..c62f8ab312f0 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -24,6 +24,12 @@ struct recoverhtlc_match { size_t *outnums; }; +struct recoverhtlc_amounts { + struct amount_sat input; + struct amount_sat fee; + struct amount_sat output; +}; + /* Key names are from the commitment owner's perspective. */ struct recoverhtlc_keys { struct pubkey our_htlc_basepoint; @@ -63,8 +69,9 @@ struct bitcoin_tx *hsmtool_recoverhtlc_sign( const struct recoverhtlc_match *match, const struct recoverhtlc_keys *keys, const u8 *destination_script, - struct amount_sat output_amount, + u32 feerate_perkw, u32 cltv_expiry, - const struct preimage *preimage); + const struct preimage *preimage, + struct recoverhtlc_amounts *amounts); #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From e0fc5634f4d45a84a9f94656c865fbf9a4abc243 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Wed, 11 Nov 2026 11:24:57 +1000 Subject: [PATCH 10/14] hsmtool: parse HTLC recovery bundles Read versioned recovery data from JSON so the command line only needs operational inputs such as the destination and fee rate. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 182 +++++++++++++++++++++++++++++++++++- tools/hsmtool-recoverhtlc.h | 23 +++++ 2 files changed, 204 insertions(+), 1 deletion(-) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 61d7fd3c32c3..1c22c8209055 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -5,15 +5,21 @@ #include #include #include +#include #include #include +#include +#include +#include #include #include #include +#include #include #include #include #include +#include #include #include @@ -174,7 +180,6 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( } return NULL; } - /* Witness stack count, signature, selector, and witness script. */ static size_t recovery_witness_weight(const struct recoverhtlc_match *match) { @@ -304,3 +309,178 @@ struct bitcoin_tx *hsmtool_recoverhtlc_sign( return tal_free(tx); return tx; } + +/* A misspelt key would otherwise be silently ignored. */ +static const char *unknown_key(const tal_t *ctx, + const char *buffer, + const jsmntok_t *obj, + const char *const *known, + size_t num_known) +{ + const jsmntok_t *key; + size_t i; + + json_for_each_obj(i, key, obj) { + bool found = false; + + for (size_t j = 0; j < num_known; j++) + found |= json_tok_streq(buffer, key, known[j]); + if (!found) + return tal_fmt(ctx, "unknown key '%.*s'", + key->end - key->start, + buffer + key->start); + } + return NULL; +} + +static const char *parse_bundle_claim(const tal_t *ctx, + const char *buffer, + const jsmntok_t *tok, + struct recoverhtlc_claim *claim) +{ + static const char *const keys[] = { + "direction", "preimage", "payment_hash", "cltv_expiry", + }; + const jsmntok_t *direction, *preimage, *hash, *cltv; + struct abs_locktime expiry; + const char *err; + + if (tok->type != JSMN_OBJECT) + return tal_strdup(ctx, "HTLC is not a JSON object"); + err = unknown_key(ctx, buffer, tok, keys, ARRAY_SIZE(keys)); + if (err) + return err; + + direction = json_get_member(buffer, tok, "direction"); + preimage = json_get_member(buffer, tok, "preimage"); + hash = json_get_member(buffer, tok, "payment_hash"); + cltv = json_get_member(buffer, tok, "cltv_expiry"); + if (!direction) + return tal_strdup(ctx, "HTLC is missing direction"); + + memset(claim, 0, sizeof(*claim)); + if (hash && !json_to_sha256(buffer, hash, &claim->payment_hash)) + return tal_strdup(ctx, "HTLC has an invalid payment_hash"); + if (cltv + && (!json_to_u32(buffer, cltv, &claim->cltv_expiry) + || !blocks_to_abs_locktime(claim->cltv_expiry, &expiry))) + return tal_strdup(ctx, "HTLC has an invalid cltv_expiry"); + + /* The peer offered it: we claim it with the preimage. */ + if (json_tok_streq(buffer, direction, "in")) { + struct sha256 preimage_hash; + + claim->kind = HTLC_THEY_OFFERED; + if (!preimage + || !json_to_preimage(buffer, preimage, &claim->preimage)) + return tal_strdup(ctx, "HTLC has an invalid preimage"); + sha256(&preimage_hash, &claim->preimage, + sizeof(claim->preimage)); + /* The hash and expiry are optional: the script needs neither. */ + if (hash && !sha256_eq(&preimage_hash, &claim->payment_hash)) + return tal_strdup(ctx, "HTLC preimage does not match" + " its payment_hash"); + claim->payment_hash = preimage_hash; + return NULL; + } + + /* We offered it: we take it back once it has timed out. */ + if (json_tok_streq(buffer, direction, "out")) { + claim->kind = HTLC_WE_OFFERED; + if (preimage) + return tal_strdup(ctx, "HTLC we offered is reclaimed" + " by timeout, not preimage"); + if (!hash) + return tal_strdup(ctx, "HTLC is missing payment_hash"); + if (!cltv) + return tal_strdup(ctx, "HTLC is missing cltv_expiry"); + return NULL; + } + + return tal_strdup(ctx, "HTLC direction is not 'in' or 'out'"); +} + +const char *hsmtool_recoverhtlc_parse_bundle( + const tal_t *ctx, const char *path, struct recoverhtlc_bundle *bundle) +{ + static const char *const keys[] = { + "version", "network", "peer_id", "channel_dbid", + "remote_htlc_basepoint", "commitment_point", "commitment_tx", + "feerate_perkw", "htlcs", + }; + const jsmntok_t *toks, *htlcs, *value; + const char *err; + char *buffer; + u32 version; + + memset(bundle, 0, sizeof(*bundle)); + buffer = grab_file_str(ctx, path); + if (!buffer) + return tal_fmt(ctx, "Could not read recovery bundle '%s'", path); + toks = json_parse_simple(ctx, buffer, strlen(buffer)); + if (!toks || toks->type != JSMN_OBJECT) + return tal_strdup(ctx, "Recovery bundle is not a JSON object"); + err = unknown_key(ctx, buffer, toks, keys, ARRAY_SIZE(keys)); + if (err) + return tal_fmt(ctx, "Recovery bundle has an %s", err); + + bundle->peer_id = tal(ctx, struct node_id); + bundle->feerate_perkw = RECOVERHTLC_DEFAULT_FEERATE; + value = json_get_member(buffer, toks, "version"); + if (!value || !json_to_u32(buffer, value, &version)) + return tal_strdup(ctx, "Recovery bundle has an invalid version"); + if (version != 1) + return tal_fmt(ctx, "Unsupported recovery bundle version %u", + version); + + value = json_get_member(buffer, toks, "network"); + bundle->network = value ? json_strdup(ctx, buffer, value) : NULL; + if (!bundle->network) + return tal_strdup(ctx, "Recovery bundle has an invalid network"); + value = json_get_member(buffer, toks, "peer_id"); + if (!value || !json_to_node_id(buffer, value, bundle->peer_id)) + return tal_strdup(ctx, "Recovery bundle has an invalid peer_id"); + value = json_get_member(buffer, toks, "channel_dbid"); + if (!value || !json_to_u64(buffer, value, &bundle->channel_dbid)) + return tal_strdup(ctx, "Recovery bundle has an invalid channel_dbid"); + if (!bundle->channel_dbid) + return tal_strdup(ctx, "Recovery bundle channel_dbid must be >= 1"); + value = json_get_member(buffer, toks, "feerate_perkw"); + if (value + && (!json_to_u32(buffer, value, &bundle->feerate_perkw) + || !bundle->feerate_perkw)) + return tal_strdup(ctx, + "Recovery bundle has an invalid feerate_perkw"); + + value = json_get_member(buffer, toks, "remote_htlc_basepoint"); + if (!value || !json_to_pubkey(buffer, value, + &bundle->remote_htlc_basepoint)) + return tal_strdup(ctx, + "Recovery bundle has an invalid remote_htlc_basepoint"); + value = json_get_member(buffer, toks, "commitment_point"); + if (!value + || !json_to_pubkey(buffer, value, &bundle->commitment_point)) + return tal_strdup(ctx, + "Recovery bundle has an invalid commitment_point"); + value = json_get_member(buffer, toks, "commitment_tx"); + bundle->commitment_tx_hex + = value ? json_strdup(ctx, buffer, value) : NULL; + if (!bundle->commitment_tx_hex) + return tal_strdup(ctx, + "Recovery bundle has an invalid commitment_tx"); + + htlcs = json_get_member(buffer, toks, "htlcs"); + if (!htlcs) + return tal_strdup(ctx, "Recovery bundle is missing HTLCs"); + if (htlcs->type != JSMN_ARRAY || htlcs->size == 0) + return tal_strdup(ctx, "Recovery bundle has no HTLCs"); + + bundle->claims = tal_arr(ctx, struct recoverhtlc_claim, htlcs->size); + for (size_t i = 0; i < htlcs->size; i++) { + err = parse_bundle_claim(ctx, buffer, json_get_arr(htlcs, i), + &bundle->claims[i]); + if (err) + return tal_fmt(ctx, "Invalid HTLC %zu: %s", i, err); + } + return NULL; +} diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index c62f8ab312f0..66c8e16dcd79 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -12,6 +12,8 @@ struct node_id; struct ripemd160; +#define RECOVERHTLC_DEFAULT_FEERATE 2500 + enum recoverhtlc_kind { HTLC_THEY_OFFERED, HTLC_WE_OFFERED, @@ -30,6 +32,24 @@ struct recoverhtlc_amounts { struct amount_sat output; }; +struct recoverhtlc_claim { + enum recoverhtlc_kind kind; + struct sha256 payment_hash; + struct preimage preimage; + u32 cltv_expiry; +}; + +struct recoverhtlc_bundle { + char *network; + char *commitment_tx_hex; + struct node_id *peer_id; + struct pubkey remote_htlc_basepoint; + struct pubkey commitment_point; + u64 channel_dbid; + u32 feerate_perkw; + struct recoverhtlc_claim *claims; +}; + /* Key names are from the commitment owner's perspective. */ struct recoverhtlc_keys { struct pubkey our_htlc_basepoint; @@ -74,4 +94,7 @@ struct bitcoin_tx *hsmtool_recoverhtlc_sign( const struct preimage *preimage, struct recoverhtlc_amounts *amounts); +const char *hsmtool_recoverhtlc_parse_bundle( + const tal_t *ctx, const char *path, struct recoverhtlc_bundle *bundle); + #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ From d5ba788b9444d462583ef94c9fa2b1ce6d43651b Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Wed, 11 Nov 2026 11:44:48 +1000 Subject: [PATCH 11/14] hsmtool: add recoverhtlc command Read a recovery bundle, validate its external inputs, and print signed transactions for each matching HTLC claim. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 136 ++++++++++++++++++++++++++++++++++++ tools/hsmtool-recoverhtlc.h | 5 ++ tools/lightning-hsmtool.c | 9 ++- 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index 1c22c8209055..ac60195ef970 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -7,10 +7,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -18,11 +20,14 @@ #include #include #include +#include #include #include #include #include +#define ERROR_USAGE 2 + static bool derive_channel_basepoints( const struct node_id *peer_id, u64 channel_dbid, @@ -180,6 +185,137 @@ struct recoverhtlc_match *hsmtool_recoverhtlc_find_outputs( } return NULL; } + +static void validate_recovery_inputs( + const struct recoverhtlc_bundle *bundle, + const char *destination, + const char *hsm_secret_path, + struct bitcoin_tx **commitment_tx, + u8 **destination_script, + struct recoverhtlc_keys *keys) +{ + chainparams = chainparams_for_network(bundle->network); + if (!chainparams || chainparams->is_elements) + errx(ERROR_USAGE, "Unsupported recovery network '%s'", + bundle->network); + if (!decode_scriptpubkey_from_addr(tmpctx, chainparams, destination, + destination_script)) + errx(ERROR_USAGE, "Invalid destination for network '%s'", + bundle->network); + *commitment_tx = bitcoin_tx_from_hex( + tmpctx, bundle->commitment_tx_hex, + strlen(bundle->commitment_tx_hex)); + if (!*commitment_tx) + errx(ERROR_USAGE, "Recovery bundle has an invalid commitment_tx"); + if (!hsmtool_recoverhtlc_derive_keys( + bundle->peer_id, bundle->channel_dbid, + &bundle->commitment_point, &bundle->remote_htlc_basepoint, + hsm_secret_path, keys)) + errx(ERROR_USAGE, "Could not derive HTLC recovery keys"); +} + +/* Parts of one payment share a script: the first claim sweeps them all. */ +static bool claim_already_swept(const struct recoverhtlc_claim *claims, + size_t n) +{ + for (size_t i = 0; i < n; i++) { + if (claims[i].kind != claims[n].kind + || !sha256_eq(&claims[i].payment_hash, + &claims[n].payment_hash)) + continue; + /* Only the script of an HTLC we offered has its expiry. */ + if (claims[n].kind == HTLC_THEY_OFFERED + || claims[i].cltv_expiry == claims[n].cltv_expiry) + return true; + } + return false; +} + +/* Sign and print one claim, or return why it has to be left out. */ +static const char *recover_claim(const struct recoverhtlc_bundle *bundle, + const struct recoverhtlc_claim *claim, + const struct bitcoin_tx *commitment_tx, + const struct recoverhtlc_keys *keys, + const u8 *destination_script) +{ + const struct preimage *preimage + = claim->kind == HTLC_THEY_OFFERED ? &claim->preimage : NULL; + struct recoverhtlc_match *match; + struct recoverhtlc_amounts amounts; + struct bitcoin_tx *tx; + + match = hsmtool_recoverhtlc_find_outputs( + tmpctx, commitment_tx, claim->kind, keys, + &claim->payment_hash, claim->cltv_expiry); + /* An HTLC below the peer's dust limit never had an output. */ + if (!match) + return "no commitment output matches it"; + tx = hsmtool_recoverhtlc_sign( + tmpctx, commitment_tx, match, keys, destination_script, + bundle->feerate_perkw, claim->cltv_expiry, preimage, &amounts); + if (!tx) + return "its value does not cover the fee"; + if (amount_sat_less(amounts.output, chainparams->dust_limit)) + return "its value after the fee is below the dust limit"; + if (amount_sat_greater(amounts.fee, amount_sat_div(amounts.input, 4))) + fprintf(stderr, + "Warning: fee %s exceeds a quarter of the" + " recovered amount\n", + fmt_amount_sat(tmpctx, amounts.fee)); + printf("%s\n", fmt_bitcoin_tx(tmpctx, tx)); + return NULL; +} + +void hsmtool_recoverhtlc(const char *argv0, + const char *bundle_path, + const char *destination, + const char *hsm_secret_path) +{ + struct recoverhtlc_bundle bundle; + struct recoverhtlc_keys keys; + struct bitcoin_tx *commitment_tx; + const char *err; + u8 *destination_script; + size_t num_recovered = 0; + + /* Transaction and signature helpers require libwally's tal allocator. */ + common_setup(argv0); + err = hsmtool_recoverhtlc_parse_bundle(tmpctx, bundle_path, &bundle); + if (err) + errx(ERROR_USAGE, "%s", err); + + validate_recovery_inputs(&bundle, destination, hsm_secret_path, + &commitment_tx, &destination_script, &keys); + if (bundle.feerate_perkw < FEERATE_FLOOR) + fprintf(stderr, + "Warning: feerate %u sat/kw is below the usual" + " relay minimum of %u sat/kw\n", + bundle.feerate_perkw, FEERATE_FLOOR); + + /* One bad or trimmed HTLC must not block recovery of the others. */ + for (size_t i = 0; i < tal_count(bundle.claims); i++) { + const struct recoverhtlc_claim *claim = &bundle.claims[i]; + + if (claim_already_swept(bundle.claims, i)) + continue; + err = recover_claim(&bundle, claim, commitment_tx, &keys, + destination_script); + if (!err) { + num_recovered++; + continue; + } + fprintf(stderr, "Skipping HTLC %zu (payment_hash %s): %s\n", i, + tal_hexstr(tmpctx, &claim->payment_hash, + sizeof(claim->payment_hash)), + err); + } + if (num_recovered == 0) + errx(ERROR_USAGE, + "No HTLC could be recovered. If no output matched," + " check the bundle's commitment_point," + " remote_htlc_basepoint, peer_id and channel_dbid"); +} + /* Witness stack count, signature, selector, and witness script. */ static size_t recovery_witness_weight(const struct recoverhtlc_match *match) { diff --git a/tools/hsmtool-recoverhtlc.h b/tools/hsmtool-recoverhtlc.h index 66c8e16dcd79..b45d0dee9584 100644 --- a/tools/hsmtool-recoverhtlc.h +++ b/tools/hsmtool-recoverhtlc.h @@ -97,4 +97,9 @@ struct bitcoin_tx *hsmtool_recoverhtlc_sign( const char *hsmtool_recoverhtlc_parse_bundle( const tal_t *ctx, const char *path, struct recoverhtlc_bundle *bundle); +void hsmtool_recoverhtlc(const char *argv0, + const char *bundle_path, + const char *destination, + const char *hsm_secret_path); + #endif /* LIGHTNING_TOOLS_HSMTOOL_RECOVERHTLC_H */ diff --git a/tools/lightning-hsmtool.c b/tools/lightning-hsmtool.c index 929d551885ce..3c85acf3367d 100644 --- a/tools/lightning-hsmtool.c +++ b/tools/lightning-hsmtool.c @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,8 @@ static void show_usage(const char *progname) printf(" - generatehsm \n"); printf(" - derivetoremote [] " "\n"); + printf(" - recoverhtlc " + "\n"); printf(" - checkhsm \n"); printf(" - dumponchaindescriptors [--show-secrets] [network]\n"); printf(" - makerune \n"); @@ -705,7 +708,11 @@ int main(int argc, char *argv[]) if (!method) show_usage(argv[0]); - if (streq(method, "decrypt")) { + if (streq(method, "recoverhtlc")) { + if (argc != 5) + show_usage(argv[0]); + hsmtool_recoverhtlc(argv[0], argv[2], argv[3], argv[4]); + } else if (streq(method, "decrypt")) { if (argc < 3) show_usage(argv[0]); decrypt_hsm(argv[2]); From 9041723e4fabb293d095226d8071ac60fcc821d5 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Wed, 11 Nov 2026 12:01:03 +1000 Subject: [PATCH 12/14] hsmtool: explain HTLC recovery transactions Show the recovered inputs, output amount, fee, transaction ID, and verification and broadcast instructions. Co-authored-by: Cursor --- tools/hsmtool-recoverhtlc.c | 75 ++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/tools/hsmtool-recoverhtlc.c b/tools/hsmtool-recoverhtlc.c index ac60195ef970..5f59487a55eb 100644 --- a/tools/hsmtool-recoverhtlc.c +++ b/tools/hsmtool-recoverhtlc.c @@ -214,6 +214,75 @@ static void validate_recovery_inputs( errx(ERROR_USAGE, "Could not derive HTLC recovery keys"); } +/* Say when the transaction becomes valid, and when that is too late. */ +static void print_recovery_timing(const struct recoverhtlc_match *match, + u32 cltv_expiry) +{ + /* Anchor outputs carry a one-block relative delay. */ + const char *confirmed = match->anchors + ? "the commitment transaction has one confirmation" : NULL; + + /* nLockTime keeps our timeout out until the HTLC has expired. */ + if (match->kind == HTLC_WE_OFFERED) { + printf("valid : once block %u is mined%s%s\n", + cltv_expiry, + confirmed ? " and " : "", confirmed ? confirmed : ""); + return; + } + + printf("valid : %s%s\n", + confirmed ? "once " : "now", confirmed ? confirmed : ""); + /* After expiry the peer's own timeout races our preimage claim. */ + if (cltv_expiry) + printf("deadline : the peer can take this back once" + " block %u is mined\n", cltv_expiry); + else + printf("deadline : the peer can take this back once" + " the HTLC expires\n"); +} + +static void print_recovery_tx( + const char *destination, + const struct bitcoin_tx *commitment_tx, + const struct recoverhtlc_match *match, + u32 cltv_expiry, + const struct bitcoin_tx *tx, + const struct recoverhtlc_amounts *amounts) +{ + struct bitcoin_txid commitment_txid, txid; + size_t weight = bitcoin_tx_weight(tx); + u32 feerate; + + bitcoin_txid(commitment_tx, &commitment_txid); + bitcoin_txid(tx, &txid); + if (!amount_feerate(&feerate, amounts->fee, weight)) + feerate = 0; + + printf("kind : %s\n", + match->kind == HTLC_THEY_OFFERED + ? "claim with preimage" : "reclaim after CLTV"); + printf("channel type : %s\n", match->anchors ? "anchors" : "no anchors"); + for (size_t i = 0; i < tal_count(match->outnums); i++) { + struct amount_sat amount = bitcoin_tx_output_get_amount_sat( + commitment_tx, match->outnums[i]); + + printf("input[%zu] : %s:%zu (%s)\n", i, + fmt_bitcoin_txid(tmpctx, &commitment_txid), + match->outnums[i], fmt_amount_sat(tmpctx, amount)); + } + printf("output : %s (%s)\n", destination, + fmt_amount_sat(tmpctx, amounts->output)); + printf("fee : %s (%zu weight, %u sat/kw)\n", + fmt_amount_sat(tmpctx, amounts->fee), weight, feerate); + printf("txid : %s\n", fmt_bitcoin_txid(tmpctx, &txid)); + print_recovery_timing(match, cltv_expiry); + printf("\n" + "Verify with `bitcoin-cli decoderawtransaction` and" + " `bitcoin-cli testmempoolaccept '[\"\"]'`.\n" + "Broadcast with `bitcoin-cli sendrawtransaction `.\n\n"); + printf("%s\n\n", fmt_bitcoin_tx(tmpctx, tx)); +} + /* Parts of one payment share a script: the first claim sweeps them all. */ static bool claim_already_swept(const struct recoverhtlc_claim *claims, size_t n) @@ -236,6 +305,7 @@ static const char *recover_claim(const struct recoverhtlc_bundle *bundle, const struct recoverhtlc_claim *claim, const struct bitcoin_tx *commitment_tx, const struct recoverhtlc_keys *keys, + const char *destination, const u8 *destination_script) { const struct preimage *preimage @@ -262,7 +332,8 @@ static const char *recover_claim(const struct recoverhtlc_bundle *bundle, "Warning: fee %s exceeds a quarter of the" " recovered amount\n", fmt_amount_sat(tmpctx, amounts.fee)); - printf("%s\n", fmt_bitcoin_tx(tmpctx, tx)); + print_recovery_tx(destination, commitment_tx, match, + claim->cltv_expiry, tx, &amounts); return NULL; } @@ -299,7 +370,7 @@ void hsmtool_recoverhtlc(const char *argv0, if (claim_already_swept(bundle.claims, i)) continue; err = recover_claim(&bundle, claim, commitment_tx, &keys, - destination_script); + destination, destination_script); if (!err) { num_recovered++; continue; From 4f71de251f5fe60f1ecc2ca32ff381925a0522f9 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Thu, 12 Nov 2026 09:40:20 +1000 Subject: [PATCH 13/14] tests: exercise hsmtool HTLC recovery Reproduce stale channel state with pending HTLCs and prove recoverhtlc signs broadcastable anchor and non-anchor spends. Cover identical MPP outputs, same-hash opposite directions, strict JSON validation, and skipped unmatched claims. Co-authored-by: Cursor --- tests/test_connection.py | 210 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/tests/test_connection.py b/tests/test_connection.py index c1dd24f779c0..f558557d6051 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,5 +1,6 @@ from fixtures import * # noqa: F401,F403 from fixtures import TEST_NETWORK +from bitcoin.rpc import JSONRPCError from decimal import Decimal from pathlib import Path from pyln.client import RpcError, Millisatoshi @@ -20,12 +21,14 @@ import coincurve import hashlib import hmac +import json import os import pytest import random import re import statistics import struct +import subprocess import time import unittest import websocket @@ -3249,6 +3252,213 @@ def test_dataloss_protection(node_factory, bitcoind): assert (closetxid, "confirmed") in set([(o['txid'], o['status']) for o in l2.rpc.listfunds()['outputs']]) +@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "sqlite3-specific DB rollback") +@pytest.mark.parametrize("anchors", [False, True]) +def test_dataloss_protection_hsmtool_recoverhtlc(node_factory, bitcoind, tmp_path, anchors): + """l2 loses state with HTLCs pending in both directions, and l1 closes + with a commitment l2 never saw. onchaind recovers to_remote, but it + cannot find the HTLC outputs without l1's per-commitment point (#9200). + + l1 gives l2 that point (`hsmtool dumpcommitments --point-only`), and + `hsmtool recoverhtlc` must sign transactions we can really broadcast: + one timeout spend for both MPP parts l2 offered, and a preimage spend + for the HTLC l1 offered, which has the same payment hash.""" + plugin = os.path.join(os.getcwd(), 'tests/plugins/hold_htlcs.py') + # Both nodes hold incoming HTLCs, so an HTLC in each direction stays + # unresolved across the state l2 is about to lose. + opts = {'plugin': plugin, + 'hold-time': 10000, + 'hold-result': 'continue'} + if not anchors: + opts['dev-force-features'] = '-23' + l1 = node_factory.get_node(may_reconnect=True, + allow_warning=True, + options=opts, + feerates=(7500, 7500, 7500, 7500)) + l2 = node_factory.get_node(may_reconnect=True, + options=opts, + broken_log='Cannot broadcast our commitment tx: they have a future one|Unknown commitment .*, recovering our funds', + feerates=(7500, 7500, 7500, 7500)) + + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + # l2 holds every payment, so push it funds to offer HTLCs with. + l1.fundchannel(l2, 10**6, push_msat=500000000) + scid = first_scid(l1, l2) + + def send_held(src, dst, amt, inv, **kwargs): + route = [{'amount_msat': amt, + 'id': dst.info['id'], + 'delay': 10, + 'channel': scid}] + src.rpc.sendpay(route, inv['payment_hash'], + payment_secret=inv['payment_secret'], **kwargs) + # The plugin sleeps in its first hook call, so it only logs once: + # lightningd logs every HTLC it hands over. + dst.daemon.wait_for_log('Calling htlc_accepted hook of plugin hold_htlcs.py') + + def l2_htlcs(state): + return [h for h in l2.rpc.listhtlcs()['htlcs'] if h['state'] == state] + + # The same hash and expiry in both directions. The two MPP parts l2 + # offers have identical scripts, so one transaction must sweep both + # without confusing them with l1's HTLC. + preimage = '55' * 32 + amt = 200000000 + inv_out = l1.rpc.invoice(2 * amt, 'held-out', 'desc', preimage=preimage) + for partid in (1, 2): + send_held(l2, l1, amt, inv_out, + amount_msat=2 * amt, partid=partid, groupid=1) + inv_in = l2.rpc.invoice(amt, 'held-in', 'desc', preimage=preimage) + send_held(l1, l2, amt, inv_in) + payment_hash = inv_out['payment_hash'] + assert inv_in['payment_hash'] == payment_hash + + wait_for(lambda: len(l2_htlcs('SENT_ADD_ACK_REVOCATION')) == 2) + wait_for(lambda: len(l2_htlcs('RCVD_ADD_ACK_REVOCATION')) == 1) + cltv = only_one(list(set(h['expiry'] for h in l2.rpc.listhtlcs()['htlcs']))) + + # Snapshot l2's db with the HTLCs pending. + l2.stop() + dbpath = os.path.join(l2.daemon.lightning_dir, TEST_NETWORK, "lightningd.sqlite3") + orig_db = Path(dbpath).read_bytes() + l2.start() + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + + # Any payment would be held too: fee updates make new commitments, + # so l1 ends up with one that l2's old db has never seen. + def l1_commit_num(): + return only_one(l1.db_query("SELECT next_index_local FROM channels"))['next_index_local'] + + for rate in (8000, 8500): + before = l1_commit_num() + l1.rpc.call('dev-feerate', [l2.info['id'], rate]) + wait_for(lambda: l1_commit_num() > before) + wait_for(lambda: len(only_one(l2.rpc.listpeerchannels()['channels'])['htlcs']) == 3) + + # Now move l2 back in time. + l2.stop() + Path(dbpath).write_bytes(orig_db) + l2.start() + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + l2.daemon.wait_for_log("Cannot broadcast our commitment tx: they have a future one") + + # l1 drops to chain (see test_dataloss_protection for the reconnect). + try: + l1.daemon.wait_for_log("They sent ERROR.*Awaiting unilateral close", timeout=10) + except TimeoutError: + try: + l1.rpc.disconnect(l2.info['id'], force=True) + except RpcError as err: + assert "Peer not connected" in err.error['message'] + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + l1.wait_for_channel_onchain(l2.info['id']) + closetxid = only_one(bitcoind.rpc.getrawmempool(False)) + bitcoind.generate_block(1) + + # onchaind only finds to_remote: the HTLC outputs are stranded. + line = l2.daemon.wait_for_log("ERROR: Unknown commitment #[0-9]+, recovering our funds!") + commit_num = int(re.search(r'Unknown commitment #([0-9]+)', line).group(1)) + + # l1 can give out exactly this point, and none of its secrets. + l1_hsm = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + l1_dbid = only_one(l1.db_query("SELECT id FROM channels"))['id'] + point = subprocess.check_output( + ["tools/lightning-hsmtool", "dumpcommitments", "--point-only", + l2.info['id'], str(l1_dbid), str(commit_num), l1_hsm], + text=True).strip() + assert re.fullmatch(r'0[23][0-9a-f]{64}', point) + + # Everything else is in l2's stale db, or on the blockchain. + chan = only_one(l2.db_query("SELECT id, hex(htlc_basepoint_remote) AS hb FROM channels")) + l2_hsm = os.path.join(l2.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + dest = l2.rpc.newaddr()['p2tr'] + htlc_out = {'direction': 'out', 'payment_hash': payment_hash, 'cltv_expiry': cltv} + htlc_in = {'direction': 'in', 'preimage': preimage, 'cltv_expiry': cltv} + bundle = {'version': 1, + 'network': TEST_NETWORK, + 'peer_id': l1.info['id'], + 'channel_dbid': chan['id'], + 'remote_htlc_basepoint': chan['hb'].lower(), + 'commitment_point': point, + 'commitment_tx': bitcoind.rpc.getrawtransaction(closetxid), + 'feerate_perkw': 7500, + # Listing both MPP parts must not sweep them twice. + 'htlcs': [htlc_out, htlc_out, htlc_in]} + + def recoverhtlc(**changes): + path = tmp_path / 'bundle.json' + path.write_text(json.dumps({**bundle, **changes})) + return subprocess.run(["tools/lightning-hsmtool", "recoverhtlc", + str(path), dest, l2_hsm], + capture_output=True, text=True) + + def rawtxs(stdout): + """Each signed tx is printed on a line of its own, as bare hex.""" + return re.findall(r'^[0-9a-f]+$', stdout, re.MULTILINE) + + # A point which is not the one for this commitment matches nothing. + bad = recoverhtlc(commitment_point=l1.info['id']) + assert bad.returncode != 0 + assert "no commitment output matches it" in bad.stderr + assert "No HTLC could be recovered" in bad.stderr + assert rawtxs(bad.stdout) == [] + + # A misspelt key is refused, rather than quietly ignored. + bad = recoverhtlc(feerate_per_kw=253) + assert bad.returncode != 0 + assert "unknown key 'feerate_per_kw'" in bad.stderr + + # An HTLC which is not there (wrong expiry) is skipped, with a reason: + # the others are still recovered. + missing = {**htlc_out, 'cltv_expiry': cltv + 1} + good = recoverhtlc(htlcs=[missing, htlc_out, htlc_out, htlc_in]) + assert good.returncode == 0, good.stderr + assert "Skipping HTLC 0 (payment_hash {}): no commitment output matches it".format(payment_hash) in good.stderr + assert "privkey" not in good.stdout + assert good.stdout.count("channel type : {}".format("anchors" if anchors else "no anchors")) == 2 + rawtx_out, rawtx_in = rawtxs(good.stdout) + + # The same, without the missing one. + good = recoverhtlc() + assert good.returncode == 0, good.stderr + assert good.stderr == '' + assert rawtxs(good.stdout) == [rawtx_out, rawtx_in] + + # Both MPP parts are swept by a single timeout transaction. + decoded = bitcoind.rpc.decoderawtransaction(rawtx_out) + assert [i['txid'] for i in decoded['vin']] == [closetxid] * 2 + assert len(set(i['vout'] for i in decoded['vin'])) == 2 + assert decoded['locktime'] == cltv + assert "valid : once block {} is mined".format(cltv) in good.stdout + + # The preimage claim has no locktime. + decoded = bitcoind.rpc.decoderawtransaction(rawtx_in) + assert only_one(decoded['vin'])['txid'] == closetxid + assert decoded['locktime'] == 0 + assert "deadline : the peer can take this back once block {} is mined".format(cltv) in good.stdout + + def wallet_output(txid): + bitcoind.generate_block(1) + sync_blockheight(bitcoind, [l2]) + out = only_one([o for o in l2.rpc.listfunds()['outputs'] if o['txid'] == txid]) + assert out['status'] == 'confirmed' + assert out['address'] == dest + return out + + # The timeout cannot be broadcast before the HTLC expires, but the + # preimage claim can. + assert bitcoind.rpc.getblockcount() < cltv + with pytest.raises(JSONRPCError, match='non-final'): + bitcoind.rpc.sendrawtransaction(rawtx_out) + txid_in = bitcoind.rpc.sendrawtransaction(rawtx_in) + assert wallet_output(txid_in)['amount_msat'] > amt - 50000000 + + # Locktime is checked against the height of the *next* block. + bitcoind.generate_block(cltv - bitcoind.rpc.getblockcount()) + txid_out = bitcoind.rpc.sendrawtransaction(rawtx_out) + assert wallet_output(txid_out)['amount_msat'] > 2 * amt - 50000000 + + @unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "sqlite3-specific DB rollback") def test_dataloss_protection_reconnect(node_factory, bitcoind): """l2's first ERROR never hits the wire: reconnect must still work.""" From d287dadcfde5c9af27d1369287389502813143b7 Mon Sep 17 00:00:00 2001 From: Sangbida Chaudhuri Date: Thu, 12 Nov 2026 09:40:20 +1000 Subject: [PATCH 14/14] docs: explain hsmtool HTLC recovery Document the recovery bundle, where each input comes from, the point-only peer workflow, transaction timing, and operator verification steps. Co-authored-by: Cursor --- doc/lightning-hsmtool.8.md | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/doc/lightning-hsmtool.8.md b/doc/lightning-hsmtool.8.md index 930b0befd3a6..f845f730ba1a 100644 --- a/doc/lightning-hsmtool.8.md +++ b/doc/lightning-hsmtool.8.md @@ -69,6 +69,82 @@ If *commitment\_point* is omitted, then the channel is assumed to have been negotiated with `option_static_remotekey`; otherwise, *commitment\_point* is the remote per-commitment point. +**recoverhtlc** *bundle\_path* *destination* *hsm\_secret\_path* + + Sign transactions which spend our HTLC outputs of a commitment +transaction which the peer broadcast after we lost channel state. +In that case **lightningd** logs `Unknown commitment #N, recovering our +funds!`: it recovers our main output, but it cannot find the HTLC outputs +without the peer's per-commitment point. + +Nothing is broadcast, and no private key is printed. +Each signed transaction pays the address *destination*, and is printed +with a description of what it spends, so you can check it and then +broadcast it yourself. + +*bundle\_path* is a JSON file describing the commitment transaction and +the HTLCs to recover from it: + +```json +{ + "version": 1, + "network": "bitcoin", + "peer_id": "02...", + "channel_dbid": 123, + "remote_htlc_basepoint": "03...", + "commitment_point": "03...", + "commitment_tx": "0200...", + "feerate_perkw": 2500, + "htlcs": [ + {"direction": "out", "payment_hash": "...", "cltv_expiry": 952295}, + {"direction": "in", "preimage": "...", "cltv_expiry": 952300} + ] +} +``` + + * *version* is always 1. + * *network* is the network name, such as `bitcoin`, `testnet4`, `signet` + or `regtest`. + * *peer\_id* and *channel\_dbid* are the node id of the peer and the `id` + of the channel in the `channels` table of our database. + * *remote\_htlc\_basepoint* is `htlc_basepoint_remote` in the same table. + The peer knows it too: it is their own HTLC basepoint. + * *commitment\_point* is the peer's per-commitment point for the + commitment they broadcast, and only the peer has it. + A Core Lightning peer can print it, and nothing secret, with + `lightning-hsmtool dumpcommitments --point-only N `, where *N* is the number in our + `Unknown commitment #N` log message. + * *commitment\_tx* is the hex of the commitment transaction, as printed by + `bitcoin-cli getrawtransaction`. + * *feerate\_perkw* is optional, in satoshi per 1000 weight: 250 is + 1 sat/vB. The default is 2500. + * Each of *htlcs* with *direction* `out` is an HTLC which we offered: it + needs the *payment\_hash* and *cltv\_expiry*, and we take it back once + it has expired. + Each with *direction* `in` is an HTLC which the peer offered: it needs + the *preimage*, and must confirm before the peer takes it back at + *cltv\_expiry*. Its *cltv\_expiry* and *payment\_hash* are optional. + +The values from our database can be read from a copy of it, for example: + +``` +sqlite3 lightningd.sqlite3 "SELECT c.id, hex(p.node_id), \ + hex(c.htlc_basepoint_remote) FROM channels c \ + JOIN peers p ON p.id = c.peer_id" +``` + +A transaction is only signed for an HTLC whose script, rebuilt from these +values, is paid by an output of *commitment\_tx*: a wrong value cannot +produce a wrong spend. +An HTLC without such an output is skipped with a message, and the others +are still recovered. An HTLC too small to have an output in the +commitment transaction cannot be recovered. +Parts of one payment which have identical scripts are spent by a single +transaction. +Both anchor and non-anchor channels are supported; Elements networks are +not. + **generatehsm** *hsm\_secret\_path* \[*lang* *seed\_phrase* \[*passphrase*\]\] Generates a new hsm\_secret using BIP39. If lang, seed\_phrase and optional passphrase are not provided they will be prompted for. *lang* can be "en" (English), "es" (Spanish), "fr" (French), "it" ("Italian"), "jp" (Japanese), "zhs" (Chinese Simplified) or "zht" ("Chinese Traditional"). Note that the seed phrase consists of multiple words, so should be surrounded by quotes.