Skip to content

Add option_independent_secrets channel type - #48

Open
davelund110 wants to merge 41 commits into
privkeyio:blake2b-unifiedfrom
davelund110:independent-secrets-blip
Open

davelund110 wants to merge 41 commits into
privkeyio:blake2b-unifiedfrom
davelund110:independent-secrets-blip

Conversation

@davelund110

@davelund110 davelund110 commented Oct 17, 2026 •

Copy link
Copy Markdown

What

This PR is Core Lightning's side of option_independent_secrets. The add-on is specified in the fork's BOLTs (lightning-blake2b/bolts#17), and Lightning Fork has it too (paulscode/lightning-fork#7, merged); the two were tested against each other.

  • What changes on a channel: on a channel with the add-on, the node accepts per-commitment secrets from its peer that don't come from a shachain, and keeps every one of them.
  • Where the secrets go:
    • Into the channel's shachain, for as long as they fit it. Secrets from a peer that uses a shachain always do, so such a peer costs nothing extra and nothing changes, down to what a static channel backup can punish.
    • From the first secret that doesn't fit, that secret and every later one go into a new table, channel_revocation_secrets, in the same database transaction as the rest of the revocation.
  • How punishment works: a revoked commitment whose secret is in the table is punished with that secret, which lightningd hands to onchaind. Older ones are punished from the shachain, as now.
  • Off by default: --independent-secrets turns it on, since the receiver of such secrets pays for storing them.
    • It can't be used with an external signer (an hsmd replaced with --subdaemon, such as VLS), which checks the peer's secrets against a shachain: lightningd refuses to start with both, or with an external signer while any channel with the add-on is open or being resolved.
    • It isn't available on Liquid.
  • Count: listpeerchannels shows independent_secrets_stored on such a channel: how many of the peer's secrets are stored one by one.
  • Cap: --max-independent-secrets (default 1,000,000; 0 for no limit; a value below 1,000,000, the lowest the spec recommends, is logged as a warning). At 90% the node logs a warning, and at the cap it starts a mutual close of the channel, as close would. A peer that keeps the closing channel updating has it closed unilaterally once it has made us store 10% more, and so does a close that hasn't finished in 48 hours.
  • When it's used: a new channel gets the add-on when both peers signal bit 267. It is carried as bit 266 in channel_type. A peer that doesn't signal it gets a normal channel.
  • Visible over RPC: listpeerchannels, listclosedchannels and the opening commands show it as independent_secrets/even.

Channels without the bit behave exactly as before. They keep their shachain, run the same checks, and store the same rows.

Size: about 1,280 added lines of C outside the tests; with tests, docs and regenerated bindings, 61 files, +3,070/−115. GitHub shows about 1,000 more lines each way, because it counts the regenerated node_pb2.py line by line; most of that is generated offsets that move whenever node.proto changes.

Why

With a shachain, every signer of a k-of-n node must hold the seed, because any of them may need to revoke a commitment.

  • Any one signer can hand that seed to the channel peer.
  • The peer then holds the secret of the node's current commitment.
  • The peer can take the whole channel through the revocation path once that commitment is broadcast.

Independent secrets let the signers generate and reveal secrets jointly.

This PR doesn't add a threshold signer. It adds what every peer must support first: accepting secrets that don't come from a shachain. For testing, it also adds a developer-only way for this node to send such secrets.

Commits

Each commit builds and passes the unit tests, so they can be reviewed one at a time.

  1. common: the feature bits, their name, the dependency on option_channel_type, and acceptance as a channel type variant.
  2. lightningd: the count of the peer's revocations and its last secret come from one place. A refactor: shachain channels see no change.
  3. wallet, lightningd: keep the peer's secrets in the shachain while they fit, and store them from the first that doesn't. Reload the count and the latest; keep only the latest once the channel is closed. Includes the migration and its guarded revert.
  4. onchaind: take a revoked commitment's secret from lightningd.
  5. doc/schemas, cln-rpc, cln-grpc: the new channel type name.
  6. lightningd: --independent-secrets (off by default, refused with an external signer), docs, listconfigs and the regenerated bindings.
  7. hsmd, lightningd: --dev-independent-secrets-sender, which needs --independent-secrets.
  8. pytest: the new tests.
  9. doc: doc/independent-secrets.md and the README.
  10. lightningd: independent_secrets_stored in listpeerchannels.
  11. lightningd: --max-independent-secrets, the warning at 90% and the mutual close at the cap.
  12. lightningd: review fixes to the cap: the 10% backstop also applies while a lease we sold runs, the cap's mutual close times out after 48 hours, and a lowered cap counts its 10% from where the channel was.
  13. lightningd: keep the backstop and the close's timeout across a restart, and re-arm the timeout only for a close we started.
  14. connectd: refuse a peer that sets the even bit, 266, in init, as the spec now requires.
  15. lightningd: hold the cap's mutual close until a pending splice locks.
  16. pytest: punish a breach on a splice's funding output with stored secrets.
  17. lightningd: start the cap's close when a pending splice is aborted.
  18. lightningd: warn about a max-independent-secrets below 1,000,000.
  19. lightningd: keep the dev sender to channels with the add-on (--dev-independent-secrets-any-channel lifts that for the test that needs it).
  20. pytest: check the spliced penalty's funding output and amount.
  21. doc: the backstop and splices, 266 in init, and the next release.
  22. lightningd: check the cap last, since its backstop can free the channel.
  23. lightningd: give the cap's close time after a restart, and keep a lowered cap's slack.
  24. lightningd: refuse the dev sender's plain channel before check returns.
  25. pytest: the cap's close after an aborted splice or RBF attempt.
  26. doc: added in v26.06.9-blake2b.10, and an aborted RBF attempt.
  27. lightningd, channeld: refuse a splice once the cap's close is coming.
  28. wallet, doc: where the backstop counts from, and the timeout with no limit.

How

Negotiation (common, lightningd/channel.c, opening_control.c, dual_open_control.c)

  • 266/267 are only allowed in init, node_announcement and channel_type, and depend on option_channel_type.
  • A peer that sets 266 in init is refused (commit 14). The spec only allows the even bit in channel_type, and now says a receiver of it in init MUST close the connection. A node without the option already refused it as an unknown even bit; one with the option knew the bit and kept the peer. Lightning Fork refuses it either way, so the two match.
  • channel_type_accept() treats 266 as a variant of both basic types, and only when we offer it. So a node without --independent-secrets refuses a channel type that includes it.
  • desired_channel_type() adds the bit when both sides have it.
    • So do fundchannel_start and openchannel_init when the caller names a type, because it changes nothing but what the receiver accepts.
    • This mirrors option_unified_sigs.

Receiving secrets (peer_htlcs.c)

  • channeld already checks that each secret is a valid key that generates the point of the commitment it revokes. That check is unchanged, and on these channels it is all that ties a secret to its commitment.
  • peer_got_revoke() checks that the revocation number equals the count, then offers the secret to the shachain, as now.
    • shachain_add_hash() checks a secret against the ones before it before it changes anything, so a refused secret leaves the shachain as it was.
    • Without the add-on, a refusal fails the channel, as now.
    • With it, the refused secret and every later one go into the table. The shachain only takes secrets in order, so once one is missing it can never take another.
  • The table is only written for a peer that doesn't use a shachain. With the dev sender (below), the first secret fits an empty shachain and the second can't, so the table holds every secret from number 1.

Reestablish (channel.c, channel_control.c, peer_control.c)

  • channel_their_revocations() and channel_their_last_secret() give the count and the latest secret: from the stored secrets once there are any (kept in memory, and reloaded at startup), and from the shachain otherwise.
  • channeld's init (which also serves a reestablish on a channel in CLOSINGD_COMPLETE), and the direct reply to a reestablish on any other closed channel, including a long-closed one, all go through them.

Database (wallet/)

  • Table channel_revocation_secrets(channel_id, commitnum, secret).
    • Its primary key is (channel_id, commitnum).
    • It has ON DELETE CASCADE, as the other per-channel tables do.
  • Loading a channel, open or closed, reads the highest stored number and its secret. No rows means the secrets are all in the shachain. Stored secrets have no gaps, so the count is that number plus one, before and after pruning.
  • wallet_channel_close() deletes all but the latest stored secret, which channel_reestablish still needs. It runs only once the channel is irrevocably resolved, never funded, or forgotten with dev-forget-channel.
  • The migration's revert refuses while a channel that hasn't closed uses the add-on. An older version would expect the next secret to extend a shachain, and fail the channel. Closed channels lose only the last secret, so they don't block it. (lightning-downgrade refuses this fork's wallets anyway, so for now the revert only runs in run-wallet.)
  • About 60 bytes per stored secret in sqlite3, for the life of the channel. Nothing for a peer that uses a shachain.
  • A column channels.independent_secrets_close_at (commit 13, with its own migration and revert) records how many secrets the channel stored when it first reached the cap, so the backstop survives a restart.

Onchain (onchain_control.c, onchaind/)

  • When the funding output is spent, lightningd unmasks the commitment number from the transaction's locktime and sequence, exactly as onchaind does.
  • If the peer revoked that commitment and its secret is in the table, lightningd looks it up and passes it in onchaind_init, along with the count. Otherwise onchaind derives it from the shachain, as now.
  • A mutual close or our latest commitment can unmask to a revoked number too. onchaind recognises those first and never uses the secret. (An older commitment of ours, from an old backup say, is taken for a revoked one of theirs, as it already is when its number is in the shachain.)
  • Restarts, reorgs and replays query the database again.

Count and cap (channel.c, peer_control.c, peer_htlcs.c, closing_control.c)

  • channel_their_stored_secrets() is the count of stored secrets: the total revocations less those the shachain holds, since the stored ones run without gaps from the first that didn't fit. listpeerchannels reports it on channels with the add-on.
  • After each stored secret, peer_got_revoke() compares the count with max-independent-secrets. It logs a warning (UNUSUAL) once the count reaches 90% of the cap (once per run, so also after a restart or a lowered cap), and at the cap calls channel_start_mutual_close().
  • channel_start_mutual_close() does what close does with no options, without a command: it moves a channel in CHANNELD_NORMAL or CHANNELD_AWAITING_LOCKIN to CHANNELD_SHUTTING_DOWN with reason local, and tells channeld to send shutdown to the channel's close address. Like close, it doesn't send a script the peer won't accept: our default P2TR address becomes P2WPKH for a peer without option_shutdown_anysegwit, and a script promised upfront that the peer can't take is logged and left alone. If channeld isn't running, it sends shutdown when it next starts, as for any channel in that state.
  • Not while a splice is pending (commit 15): BOLT 2 doesn't allow shutdown then. The cap logs that it will close once the splice locks, and handle_peer_splice_locked() starts the close at the lock. If the splice is aborted instead, handle_splice_abort() checks the cap again once channeld restarts (commit 17, tested in commit 25). An aborted RBF attempt only drops that attempt, so the close then keeps waiting for the splice it would have replaced. The backstop below still applies meanwhile, and is all that bounds a splice that never confirms.
  • Nor can a splice start once the close is coming (commit 27). peer_got_revoke() starts the close before it replies to channeld, so channeld, which waits for that reply, queues the shutdown request and handles it before anything else the peer sends. channeld then stops splicing once either side has sent shutdown, or it has been asked to send one: a splice_init or tx_init_rbf from the peer gets a warning and the connection is closed, as BOLT 2 asks of a node that has received shutdown (we reestablish with ours); a local splice_init is refused; and a local one still waiting for quiescence is cancelled the same way. The backstop, which can free the channel, still runs last in peer_got_revoke().
  • After shutdown, the peer only needs as many more commitments as it takes to settle the HTLCs in flight. But it can keep a closing channel updating with update_fee while HTLCs remain, so once it has made us store 10% more than the cap (at least one more), channel_fail_permanent() closes the channel unilaterally. Storage stays bounded either way.
  • If the mutual close hasn't finished in 48 hours, channel_fail_permanent() closes the channel unilaterally, as close does when its default timeout runs out. At startup the timeout is armed again only for a close we started (its last state change to CHANNELD_SHUTTING_DOWN has our reason), with whatever is left of its time since then, but at least a tenth of the timeout, up to 10 minutes (so 10 minutes by default), so the peer can reconnect and finish even if we were down longer than the timeout; a clock that has gone back counts as no time (commit 23). A close the user or the peer started is left alone.
  • The 10% counts from where the channel was when we started our close, saved in the database (independent_secrets_close_at); before then (a lease, a pending splice, a close someone else started, or one that couldn't start), from where we first found it at the cap. So a cap lowered below what a channel already stores never fails the channel at its next update, a restart doesn't give the peer another 10%, and a close that starts once a lease expires or a splice locks gets its own 10% for the HTLCs in flight: at most about 1.2 times the cap in all. A cap raised since counts from itself.
  • check_independent_secrets_cap() runs last in peer_got_revoke(), after the channel is saved and the commitment_revocation hook is called: at the backstop, failing a channel whose funding we withheld forgets it, which frees it (commit 22).
  • Like close, it keeps a lease we sold: a channel the peer leased from us isn't closed mutually before the lease expires, but the 10% backstop still applies, since our funds stay locked until the lease expires however the channel closes. (Commit 12; before it, a peer could make us store without limit until expiry.)

Dev sender (hsmd, lightningd)

  • With --dev-independent-secrets-sender (developer mode only, sent in hsmd_dev_preinit), hsmd derives this node's secrets as HMAC-SHA256(shaseed, "independent-per-commitment-secret" || n || counter), the same function as Lightning Fork's dev sender. It applies to every channel, so it is only for testing against peers that support the option.
  • As the spec requires of a node whose secrets don't come from the seed, it then only opens and accepts channels with the add-on (commit 19): fundchannel_start and openchannel_init refuse a type without it, before check would return (commit 24), and so do both opening hooks' offers. --dev-independent-secrets-any-channel lifts that, for the test that shows such a channel failing: the peer refuses our second secret (the first always fits an empty shachain).
  • Every derivation site uses it.
  • It applies to every channel, so lightningd refuses to start with it changed while any channel is open or being resolved. Otherwise that channel would fail, and our own unilateral close couldn't be swept.
  • It needs --independent-secrets, since only a peer with the add-on accepts its secrets.

Bindings

  • The channel type name is a closed enum in the schemas, cln-rpc and cln-grpc, so it is added to all three. Without that, Rust clients and plugins would fail to deserialize a channel with the add-on.
  • listconfigs gains independent-secrets and max-independent-secrets, and listpeerchannels gains independent_secrets_stored.
  • The bindings are regenerated:
    • msggen produced .msggen.json, node.proto, model.rs, convert.rs and grpc2py.py.
    • The *_pb2.py files were generated with protoc and put in the layout of the pinned grpcio-tools 1.75.1 (protobuf 6.31.1). Applied to the previous .proto files, the same method reproduces the committed *_pb2.py files byte for byte.

Prior work

This implements the "remove shachain" option that ZmnSCPxj first proposed on Delving Bitcoin as no_more_shachains (Option 2A in his September summary, https://delvingbitcoin.org/t/towards-a-k-of-n-lightning-network-node/2395), negotiated per channel through channel_type rather than with node-level bits.

His version has an odd bit meaning "I still send shachain secrets", so the other side can keep them compactly. Here the receiver finds that out for itself, from whether each secret still fits its shachain, so no bit is needed. The lnd PR covers the other approaches.

Review

A first review found no defect that could lose funds, and nothing that breaks the protocol or the database. Its findings, all addressed:

  • hsmd_wire.csv changed without a line in common/hsm_version.h, which check-hsm-versions would catch. Added.
  • The dev sender applies to every channel. Flipping it under existing channels would leave them failing and our own unilateral close unspendable. lightningd now refuses to start in that case, and a test covers it.
  • The tests didn't run with dual-funded opens. Most of them now carry both openchannel markers, and pass both ways.
  • The config docs said secrets are pruned when the channel closes. They now say once its outputs are resolved.

Left as is: a caller who names a channel type gets the add-on whenever both peers support it, with no per-channel opt-out, as with option_unified_sigs.

One design change came after the review. The first version stored every secret in the table and left the shachain empty on these channels. test_emergencyrecoverpenaltytxn showed what that cost: a static channel backup carries the shachain, so a node restored from one could no longer punish any old state on such a channel. With the add-on on by default, as it was then, that would have been every new channel, including all those with peers that use a shachain. Secrets now stay in the shachain while they fit, which limits the loss to peers that really do send independent secrets. The spec PR was amended to allow this: the receiver MAY keep secrets in the compact form for as long as each one fits, and MUST store them individually from the first that doesn't.

A second review then went through that design: the switch-over, the boundaries between the shachain's numbers and the table's, crashes mid-revocation, onchaind restarts and replays, pruning, and the backup path. It found no defect that could lose funds. Its low-severity notes are either documented limits (backups, storage growth) or behave as before this PR (a revocation still queued when the funding spend is seen). One more: a duplicate row would stop lightningd with a database error rather than fail the channel, but nothing a peer sends can cause one.

Maintainer review

Kyle's review found that a sold lease skipped the cap's backstop, leaving storage unbounded until the lease expired, and that the cap's mutual close needed a timeout. Commit 12 fixes both, and also counts a lowered cap's 10% from where the channel was. It comes with three new tests (lowered cap, close timeout, lease). Our own review had flagged the same two points.

His second review confirmed both and found that a restart reset the 10% and the timeout, and re-armed the timeout for closes we didn't start. Commit 13 saves the count at which our close started and arms the timeout only for our close, with the time it has left. It comes with two more tests (restart, a close we didn't start).

Paul, reviewing alongside the Lightning Fork side, found that nothing tested a breach on a splice's funding output with stored secrets, which the Lightning Fork side had needed a fix for, and that the cap's close could send shutdown while a splice wasn't locked. Commit 16 adds the test (a revoked commitment on the new funding, broadcast while the splice is pending and after it locked; both are punished, since secrets are stored by commitment number), and commit 15 holds the close until the splice locks, with a test that fails without it.

His second pass found five smaller points, all addressed: a splice aborted at the cap now starts the close at once (commit 17); a limit below the spec's recommended minimum is logged (commit 18); the dev sender keeps to channels with the add-on, as the spec requires (commit 19); the spliced-breach test checks the funding output and the amount (commit 20); and the docs cover when the backstop closes without a mutual close, the splice wait, 266 in init, and the next release's name (commit 21).

His third pass confirmed those, and found that v26.06.9-blake2b.9 was tagged without this PR: "added in" now says v26.06.9-blake2b.10 (commit 26), and the branch is rebased onto .9. Of his optional points, the docs now say that an aborted RBF attempt keeps the close waiting for the splice under it (commit 26), the re-check after an aborted splice has a test of its own (commit 25), and check fundchannel_start reports the dev sender's refusal (commit 24).

His fourth pass found nothing more to fix in commits 22 to 26, and checked each fix by reverting it: its test fails. It had two small notes, both in commit 28: the comment on the independent_secrets_close_at migration now says where the 10% counts from, and the docs say that the timeout of a close already started stays if the limit is then set to 0, as close's does.

Commit 14 comes from the spec review on lightning-blake2b/bolts#17, which found that the two implementations treated 266 in init differently. The spec's storage limit rules are unchanged for this PR: the default cap (1,000,000) is the minimum the spec now recommends, and the 10% backstop is the margin it asks for revocations our own updates cause.

Security review

A further security review then read the whole series, as an attacker would. It found nothing that could lose funds, and confirmed the penalty path, secret receipt, reestablish, the database and the dev sender. It found:

  • A use-after-free (fixed in commit 22). At the backstop, channel_fail_permanent() on a channel whose funding we withheld (a zeroconf channel opened with withhold=true) forgets it, which frees it, and peer_got_revoke() went on to save it. Under valgrind the new test reports invalid reads and lightningd crashes; with the fix it's clean, and the channel stays forgotten across a restart.
  • The close's timeout after a restart (fixed in commit 23). It was re-armed with 1 second if we had been down longer than the timeout, or if the clock had gone back, so the channel was closed before the peer could reconnect.
  • A lowered cap (fixed in commit 23). On a leased, splicing or already-closing channel, it failed the channel at its next update.
  • Left as is: pruning a closed channel's secrets deletes up to max-independent-secrets plus 10% rows in one statement; the cap is per channel, so a peer with many channels can make us store it on each (documented); and the external-signer check only sees an hsmd given with --subdaemon.

A second security review then went over the whole series again, in two parts (secret storage, reestablish, punishment and the database; and the cap, negotiation, options and the dev sender), with tests of its own. The first part found nothing to fix: no way to lose funds, crash lightningd, corrupt the database, desync the count or get a bad secret accepted, including through a reorg that swaps one revoked commitment for another (tried in a test), and three claims in this description that were broader than the code (now corrected). The second found:

  • A splice started as the cap's close begins (fixed in commit 27). A peer that sent stfu and splice_init right after the revoke_and_ack reaching the cap got its splice accepted: channeld learned of the shutdown only afterwards, held it while quiescent, and sent it once the splice was signed but unlocked, which BOLT 2 forbids. lightningd went from CHANNELD_SHUTTING_DOWN back to CHANNELD_AWAITING_SPLICE, the mutual close spent the old funding output and conflicted with the splice, and the channel stayed in CLOSINGD_COMPLETE until a restart. No funds were at risk: a node in that state still sees a commitment spending the splice's output and handles it. Parts of this are older than this PR: see "Older splice issues" at the end.
  • The 10% after a lease or splice wait. Once our close starts, the 10% counts from there, so a close that starts when a lease expires or a splice locks gets its own 10% for the HTLCs in flight. Storage stays below about 1.2 times the cap; the description said otherwise and now matches the code.

Maintainer decisions

After the first round, the fork's maintainers decided three design points, all in this version:

  • Off by default, with --independent-secrets to turn it on.
  • Off with external signers: refused at startup with an hsmd replaced by --subdaemon.
  • A count and a configurable cap: independent_secrets_stored in listpeerchannels, and --max-independent-secrets, which closes a channel at the cap.

Tests

After the bits moved from 516/517 to 264/265, check-units and the pytest files that check feature bits and channel types were rerun (test_independent_secrets, test_misc, test_opening, test_plugin, test_connection, test_gossip, test_splicing, test_closing: 578 passed), along with all nine interop scenarios. Every failure was one of the environment-only ones listed below, apart from test_htlc_rexmit_while_closing, which failed once under load and passed on rerun, and the timing test run-secret_eq_consttime, which passes when not run alongside the rest. The full-suite numbers below are from the run with 516/517: only constants, test expectations and docs changed since. The bits then moved once more, to 266/267, because an open bLIP PR already claims 264/265. That again changed only constants, test expectations and docs (8 files).

The branch was then rebased, without conflicts, onto the fork's blake2b-unified at 1dd56e94d: the v26.06.9-blake2b.7 release (482c8c521) plus the fork's new test vectors and a doc change. On that base, with the bits at 266/267, it builds, and these were rerun: the run-channel_type, run-wallet and run-grind_feerate unit tests; test_independent_secrets.py and the feature-bit and channel-type tests in test_misc, test_opening, test_plugin, test_connection and test_splicing (24 passed); and all nine interop scenarios.

After the maintainers' decisions (off by default, refused with an external signer, the count and the cap), and a further review of that code whose findings are all addressed (the option on Liquid, an external signer with existing add-on channels, the backstop for a peer that keeps a closing channel updating, the close address and states, the warning), the branch was rebased, without conflicts, onto blake2b-unified at 13391ab17. On that base every commit builds and passes the run-channel_type, run-wallet, run-invoice-select-inchan and run-grind_feerate unit tests, and these pass with the final code: test_independent_secrets.py, all of test_splicing.py, and the reestablish, backup-recovery (with and without the add-on), feature-bit and channel-type tests in test_closing, test_connection, test_security_backport, test_misc, test_opening and test_plugin (55 passed); test_independent_secrets.py again with dual-funded opens (all passed, the v1-only tests skipped); the penalty, onchain and closing tests in test_closing.py (61 passed, 1 skipped); test_independent_secrets.py as it stands at each commit that changes it (the tests, the count and the cap commits); and all nine interop scenarios, with CLN started with --independent-secrets.

The branch was then rebased again, without conflicts, onto blake2b-unified at 7974591c7 (the fork's splice shared-input signature check, coinbase-input refusal, reserve and xpay fixes). On that base it builds and these pass again: the four unit tests above; test_independent_secrets.py, all of test_splicing.py and test_splicing_disconnect.py, and the backup-recovery, feature-bit, channel-type and reestablish tests (58 passed); test_independent_secrets.py with dual-funded opens (13 passed, the v1-only tests skipped); and all nine interop scenarios against Lightning Fork 0.21.4-beta-blake2b.20.

Commits 12 and 13 (the review fixes) build and pass the four unit tests above and run-db, and test_independent_secrets.py passes in full both ways: 21 passed with v1 opens (the lease test runs with dual-funded opens only), and 14 passed with dual-funded opens (the v1-only tests skipped), including the five new cap tests.

Commits 14 to 16 come from the spec review and Paul's review. With them, the branch was rebased onto blake2b-unified at e48ea5bad. Three files conflicted, all because the base added things where ours go: a migration (ours now come after it), an hsmd wire message (the hsm_version.h line for the dev sender has the new hash, and check-hsm-versions passes), and a test that rewinds the database by one migration (test_splice_sent_sigs_marked_on_upgrade, which now drops our table and column and rewinds by three, as test_splice_stored_feerate_repaired_on_upgrade now does by six). On that base:

  • run-wallet, run-db, run-channel_type and run-invoice-select-inchan pass, and so does check-hsm-versions.
  • test_independent_secrets.py passes both ways: 26 passed with v1 opens (the lease test runs with dual-funded opens only), and 17 with dual-funded opens (the v1-only tests skipped). The new tests for commits 14 and 15 fail without them.
  • All of test_splicing.py passes (61 passed), including both tests that rewind the database.
  • All nine interop scenarios pass against Lightning Fork at 9b0d0b3.

Commits 17 to 21 come from Paul's second pass. On the same base, every commit builds, and with all of them:

  • check-units passes in full.
  • test_independent_secrets.py passes both ways: 27 passed with v1 opens, and 18 with dual-funded opens. The new tests for commits 18 and 19 fail without them.
  • All of test_splicing.py passes (61 passed), including its splice-abort tests.
  • All nine interop scenarios pass against Lightning Fork's merged blake2b (the same code as 9b0d0b3).

Commits 22 and 23 come from the security review. With them: check-units and check-hsm-versions pass; test_independent_secrets.py passes both ways (31 passed with v1 opens, 19 with dual-funded opens); all of test_splicing.py passes (61 passed); the seven cap tests pass under valgrind with no errors; and all nine interop scenarios pass. The new tests for commits 22 and 23 fail without them (commit 22's under valgrind).

Commits 24 to 26 come from Paul's third pass. With them, the branch was rebased onto blake2b-unified at bf4e4ea6b (the v26.06.9-blake2b.9 release) without conflicts; the base's only change since e48ea5bad is a splice restart fix in peer_control.c and test_splicing.py. On that base every commit from 22 on builds, and with all of them:

  • check-units and check-hsm-versions pass.
  • test_independent_secrets.py passes both ways: 33 passed with v1 opens, and 21 with dual-funded opens.
  • All of test_splicing.py passes (62 passed).
  • Under valgrind, the cap tests that commits 22 to 25 touch and the dev sender's test pass with no errors (11 tests).
  • All nine interop scenarios pass against Lightning Fork's blake2b at b13cb18 (0.21.4-beta-blake2b.21, with feature, routing: treat 266 in a node announcement as unknown paulscode/lightning-fork#13).
  • The aborted-splice test fails without commit 17's re-check, and the sender test fails without commit 24 (with v1 opens: openchannel_init already refused before check).

Commit 27 comes from the second security review. On the same base, with it:

  • check-units passes.
  • test_independent_secrets.py passes both ways: 34 passed with v1 opens, and 22 with dual-funded opens.
  • All of test_splicing.py and test_splicing_disconnect.py pass (65 passed), since commit 27 touches channeld's splice code.
  • Under valgrind, the new test and the other cap tests around splices and the backstop pass with no errors.
  • All nine interop scenarios pass against Lightning Fork's blake2b at b13cb18.
  • The new test fails without it: the peer's splice is accepted.

New (tests/test_independent_secrets.py; most run with v1 and v2 opens):

  • The even bit in init: a peer setting 266 in init is refused, with and without the option; one setting 267 connects.
  • The dev sender keeps to the add-on: with --dev-independent-secrets-sender a node won't open a channel with a peer that doesn't offer 267 (and check of the opening command says so), nor accept one without the add-on, and opens one with a peer that has it.
  • Splices: a revoked commitment on a splice's funding output, broadcast while the splice is pending and after it locked, spends the splice transaction and is punished with the stored secret; the penalty takes the cheater's to_local, which matches its balance less the commitment's fee and anchors. Reaching the cap while a splice is pending sends no shutdown; the close starts once the splice locks.
  • A splice at the cap: a peer that starts a splice right after the revocation that reaches the cap has it refused, and the close completes on the old funding output.
  • Aborted splices: a splice aborted with the channel past a limit lowered while we were down starts the close without another revocation. An RBF attempt aborted at the cap leaves the close waiting for the splice under it, and the close starts once that splice locks.
  • Negotiation: used between CLN nodes with --independent-secrets, not without; added to a type the caller names; refused by a node that doesn't offer it; bit 267 in init and node_announcement only with the option, and in listconfigs.
  • Refused options: lightningd won't start with --independent-secrets and --subdaemon=hsmd:..., or with the dev sender but without --independent-secrets. Replacing hsmd without the option still works, but not while a channel with the add-on is open.
  • Kept without the option: a channel opened with the add-on, after a restart without --independent-secrets, keeps storing the peer's secrets and punishes a revoked commitment with one; the node no longer signals bit 267.
  • Backups: test_emergencyrecoverpenaltytxn also runs with the add-on: a peer that uses a shachain is still punished from a restored backup.
  • Count: independent_secrets_stored matches the rows stored, across restarts; it is 0 with a shachain peer and absent without the add-on.
  • Cap: with max-independent-secrets=20, the startup warning says the limit is below 1,000,000 (a node at the default doesn't log it); the 90% warning comes once, at 18 stored secrets, with the channel still normal, and the 20th starts a mutual close while a payment is in flight. The payment completes (one more secret, within the slack), the close confirms, and state_changes records the reason.
  • Backstop: with max-independent-secrets=10 the slack is one secret, so settling the payment in flight when the close starts is one too many: the channel is closed unilaterally, with its reason in state_changes.
  • Lowered cap: a channel storing 29 when the cap is lowered to 20 starts a mutual close at 30, the payment in flight settles at 31 within the slack counted from 30, and the close confirms without a unilateral close.
  • Withheld channel at the backstop: a zeroconf channel we opened with withhold=true and a peer using the dev sender reaches the backstop; the channel is forgotten, stays forgotten across a restart, and valgrind reports nothing.
  • Restart late: after a restart longer than the timeout, the close gets a tenth of it; with the clock gone back an hour, all of it.
  • Lowered during a splice: a cap lowered below what a channel stores while a splice is pending doesn't fail it; the close starts when the splice locks.
  • Close timeout: the peer disconnects instead of answering our shutdown and doesn't come back; after the timeout (10 seconds in the test, through a developer option) we close unilaterally, with its reason in state_changes.
  • Restart: the close started at the cap is saved with the count it started at (20); after a restart 10 seconds in, the timeout has at most 20 of its 30 seconds left, and then closes the channel unilaterally.
  • Not our close: the user closes a channel with no timeout while the peer is away, and the cap is lowered below what it stores before a restart: no timeout is armed, and the close keeps waiting.
  • Lease: on a channel the peer leased from us (dual-funded), reaching the cap logs that we'll close after the lease, starts no mutual close, and the backstop still closes the channel unilaterally once the peer makes us store one more.
  • Unchanged without it: a shachain channel stores nothing in the new table.
  • A shachain peer costs nothing: on a channel with the add-on, a peer that uses a shachain has every secret kept in it, across a restart. Nothing goes in the table.
  • Storage: both sides use the dev sender. From secret 1 on, every secret is stored, and the channel survives restarts and reconnections. Each side checks that your_last_per_commitment_secret is the one it revealed.
  • The feature is needed: a dev sender with --dev-independent-secrets-any-channel, against a node without the add-on, has its second secret refused (Bad per_commitment_secret ... for 1). This shows the other tests really give the receiver secrets no shachain would accept.
  • Startup guard: a node with a channel won't start with the dev option flipped, and starts again when it is restored.
  • Penalties:
    • The cheater uses the dev sender, broadcasts a revoked commitment whose secret is in the table, and loses everything.
    • With a restart before the breach, and of onchaind after it.
    • With an HTLC on the revoked commitment.
    • Commitment 0, whose secret is in the shachain part, is punished from there.
  • Their unilateral close of the latest commitment: no penalty, and no complaint about a missing secret.
  • Pruning: once the channel is forgotten, one secret is left. The node still answers a reestablish for it with that secret after a restart, and the peer checks it.

Also new:

  • run-wallet: the shachain taking secret 0 and refusing secret 1; storing, loading, looking up, pruning on close, the closed-channel loader, and the guarded revert. Eight mutations of that code (wrong number stored, wrong secret looked up, count off by one, either loader skipped, pruning everything or nothing, the revert unguarded) are each caught.
  • run-channel_type: names and acceptance.

Interop with Lightning Fork, on a Bitcoin Knots BLAKE2b regtest: nine scenarios, each on a fresh chain, all passing on v26.06.9-blake2b.7 and again on blake2b-unified at 13391ab, 7974591, e48ea5b and bf4e4ea with the bits at 266/267 (script interop.py and its plugin hold.py, in https://github.com/davelund110/independent-secrets-interop).

lnd sends KDF secrets, CLN keeps them. This needs lnd's integration build, the only one with its dev sender. That build never signs with the unified sighash, so CLN ran with --dev-force-features=-515: it requires option_unified_sigs on new channels while it offers it. Channels opened as [12, 22, 266] (static remote key, anchors, independent secrets).

  • lnd-cheats: CLN keeps lnd's secret 0 in its shachain and stores 1 to 27. lnd's database is restored from a copy, and it force-closes with revoked state 12 of 28. CLN takes lnd's whole balance with the stored secret, and keeps one secret once the channel is closed.
  • lnd-cheats-htlc: the same with an HTLC held in flight on the revoked commitment, and CLN restarted before its penalties confirm. CLN takes lnd's balance, and the HTLC by penalising the output of lnd's HTLC-timeout. It can't take the HTLC directly here because of a fee bug that predates this PR (below). With that fixed, it takes the HTLC with its first penalty.
  • lnd-long: 100 rounds of payments, with each side restarted in between. CLN stores 399 secrets. lnd broadcasts state 200 of 400, CLN punishes it, and keeps one secret after the close.
  • lnd-coop: a cooperative close, after which CLN keeps one secret.

CLN sends KDF secrets, lnd keeps them. These use a release build of lnd and an unmodified CLN, so the channels also had option_unified_sigs: [12, 22, 266, 514].

  • cln-cheats: both nodes restart and reestablish, each checking your_last_per_commitment_secret, and keep paying. CLN's table stays empty, since lnd's own secrets come from its shachain. CLN broadcasts revoked state 13, and lnd's breach arbiter takes everything: it ends with 990,347 of the 1,000,000 sat.
  • cln-cheats-htlc: an HTLC in flight on the revoked commitment, broadcast while lnd is down. lnd finds the breach when it starts and takes everything, the HTLC included.
  • cln-long: 400 updates with restarts, then a state from the middle. lnd takes everything.
  • cln-coop: a cooperative close.

A peer that uses a shachain. shachain-peer: a release lnd sends ordinary secrets. CLN stores none, and punishes revoked state 12 from its shachain.

A pre-existing bug the interop found. onchaind_tx_unsigned() leaves the witness's stack element (a revocation pubkey or a preimage) out of a spend's weight. When feerate estimates are at the floor, as on a regtest without any, an HTLC penalty or fulfill then pays less than the minimum relay fee, and bitcoind refuses it for as long as the estimates stay there. Upstream Core Lightning has the same line. It isn't part of this PR: A separate PR (#49) fixes it, with a test that fails without it.

Existing tests: in the earlier version, with the add-on on by default, every CLN-to-CLN channel in the suite used it. Their secrets stayed in the shachain, so the existing penalty, onchain, reestablish and backup tests checked that nothing changed for shachain peers. The whole pytest suite (all 36 files) was run with that code: 1,070 passed. Now that it is off by default, those tests run as they do without this PR; the ones the earlier version had to adjust for the extra bit are back to upstream's.

  • test_closing.py and test_connection.py: 196 passed.
  • test_opening.py, test_misc.py, test_plugin.py, test_splicing.py and the new tests: 321 passed, including test_emergencyrecoverpenaltytxn, which punishes from a restored backup.
  • The other 29 files: 553 passed.
  • Every test that failed or errored was then run on an untouched build of the fork's base, and fails there in the same way. They need network access (test_currencyrate.py, the test_bookkeeper.py reports that use currency rates, test_reckless.py, test_announce_and_connect_via_dns), modules or Rust plugins this sandbox doesn't have (test_cln_lsps.py, test_cln_rs.py, test_clnrest.py), man pages (test_cli_multiline_help), a user other than root (test_setconfig_access), or IPv6 (test_ipv4_and_ipv6, and test_websocket's teardown).
  • The new tests, and test_channel_lease_lessor_cheat / test_channel_lease_lessee_cheat (dual-funded penalties), with EXPERIMENTAL_DUAL_FUND=1.
  • check-units at every commit of the series; check-source-bolt for the changed files, and the quotes from the draft bLIP against its text; check-includes, check-config-docs, check-hsm-versions, flake8 and rustfmt (edition 2024).

Not run here:

  • The Rust crates. crates.io isn't reachable from this sandbox, so cln-rpc and cln-grpc were only checked with rustfmt, and test_cln_rs.py, which exercises them, couldn't import.
  • check-doc-examples. The fork hasn't regenerated the doc examples for unified_sigs/even either. The listconfigs example gained the two new options by hand.

Limits

  • Static channel backups carry the shachain, not the table.
    • With a peer that uses a shachain, nothing changes: a restored node can punish any old state.
    • With a peer that sends independent secrets, a node restored with recoverchannel or emergencyrecover can only punish states revoked before the first secret that didn't fit. With the dev sender, that is state 0 alone.
    • Recovering its own balance works as usual. lnd's backups never carried revocation state.
  • External signers: the built-in hsmd doesn't check the peer's secrets (hsmd_validate_revocation is a stub; channeld does the check). A signer that keeps them in a shachain of its own, such as VLS, would refuse the first that doesn't fit, so lightningd refuses an hsmd replaced by --subdaemon with --independent-secrets, or while a channel with the add-on is open. A signer that supports the add-on later can lift that.
  • The cap closes a channel. A channel that reaches max-independent-secrets is closed cooperatively, or unilaterally if the peer keeps it updating past 10% more or the close hasn't finished in 48 hours. A channel the peer leased from us is only closed mutually once the lease expires, and one with a pending splice once the splice locks or is aborted (an aborted RBF attempt leaves the splice under it pending); the 10% backstop applies before then, so it can close a channel unilaterally without a mutual close having been tried. There is no limit across channels: a peer with several such channels can make us store the cap on each.
  • Downgrading:
    • An older build refuses to start on a database this one has upgraded.
    • lightning-downgrade already refuses wallets of this fork.

Rollout

  1. Now: off by default here, turned on with --independent-secrets. Lightning Fork, which has merged its side, ships it off by default too in its first release with it, and turns it on by default in the release after. New channels use it when both sides offer it. Channels with older nodes open as before.
  2. Later: make it required in the BOLTs, so multi-signer nodes can open channels with any peer.

Release note

Independent per-commitment secrets. New channels with peers that support option_independent_secrets (bits 266/267) accept per-commitment secrets that don't come from a shachain. That is what lets a peer run its node with k-of-n signers without any one signer being able to steal the channel. Secrets from a peer that uses a shachain are kept as before; others are stored one by one. It's off by default: independent-secrets turns it on (not with an external signer). listpeerchannels shows it in the channel type as independent_secrets/even, and how many secrets are stored as independent_secrets_stored; max-independent-secrets (default 1,000,000) closes a channel that stores that many. A node restored from a static channel backup can't punish an old state whose secret was stored one by one. See doc/independent-secrets.md.

Not in this PR

  • Sender side: this node's own secrets still come from its shachain, which the spec allows. The KDF in hsmd is for testing only, and a k-of-n generator is a separate project.
  • Bit numbers: 266/267 are proposed in BOLT 2, 3, 9: option_independent_secrets lightning-blake2b/bolts#17. An earlier version used 516/517, next to the fork's own bits, until a reviewer pointed out that nothing here is specific to BLAKE2b. The next version used 264/265, until René Malmgren found that an open bLIP PR (blip-0073: Path Queries lightning/blips#73) already claims them: the bLIP repo's table only lists merged bLIPs. No open bLIP PR claims 266/267.
  • Version: the listconfigs and listpeerchannels schemas and the config docs say "added in v26.06.9-blake2b.10", since .9 was tagged without this. Change that if the release is named differently.

Older splice issues

Found while reproducing the splice race above. They don't depend on this PR, and it doesn't change them:

  • close during a pending splice. json_close accepts CHANNELD_AWAITING_SPLICE, and channeld then sends shutdown while the splice is unlocked (the DTODO in maybe_send_shutdown()), which BOLT 2 forbids.
  • A splice that confirms after a mutual close. closed_inflight_splice_found() is meant to close the channel unilaterally then, but drop_to_chain() takes the splice transaction for a unilateral close it has already seen ("Not dropping our unilateral close onchain since we already saw … confirm") and broadcasts nothing, so the channel stays in CLOSINGD_COMPLETE until lightningd restarts. A commitment the peer broadcasts on the splice's output is still seen and handled.
  • splice_init doesn't return when the peer closes the connection after our splice.

@kwsantiago kwsantiago left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A sold lease skips the cap's backstop, so storage is unbounded until expiry; the cap's mutual close also needs a timeout.

@davelund110

Copy link
Copy Markdown
Author

A sold lease skips the cap's backstop, so storage is unbounded until expiry; the cap's mutual close also needs a timeout.

Thanks, both fixed in b2e57b6: the backstop now applies during a lease (we still don’t start the mutual close before it expires), and the cap’s mutual close times out after 48h, like close’s default. A lowered cap also counts its 10% from where the channel was now. There are three new tests: lease, timeout, lowered cap.

@kwsantiago kwsantiago left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed, thanks. A restart resets the slack and timer, and re-arms closes we didn't start.

@davelund110

Copy link
Copy Markdown
Author

Good catch, fixed in 4df42d5: the count at which our close started is now saved in the db (a new column), so the backstop doesn't reset on a restart, and at startup the timeout is armed only for a close we started, with the time it has left. There are two more tests: restart mid-close, and a user's close left alone.

@paulscode

Copy link
Copy Markdown

Two small things from reading this alongside the LND side:

  • There's no test of a breach on a splice's funding output with stored
    secrets. Looking at independent_revocation_secret() it should work, since
    secrets are keyed by commitment number, but the LND side needed a dedicated
    fix for exactly this case, so a test would be good to have.
  • channel_start_mutual_close() accepts CHANNELD_AWAITING_SPLICE, so the
    cap's close can send shutdown while a splice isn't locked, which BOLT 2
    doesn't allow. close does the same today, but here nobody asked for it.
    Waiting until the splice locks would avoid it; the backstop still bounds
    storage meanwhile.

@davelund110
davelund110 force-pushed the independent-secrets-blip branch from 6aa6ca8 to 8aebea0 Compare October 21, 2026 17:23
@davelund110

Copy link
Copy Markdown
Author

Thanks Paul, both done:

  • 983e677: the cap's mutual close now waits for a pending splice to lock (channel_start_mutual_close no longer accepts a channel with a pending splice, and the close starts from handle_peer_splice_locked). The backstop still bounds storage meanwhile. The new test fails without it.
  • 8aebea0: a test of a breach on a splice's funding output with stored secrets, broadcast while the splice is pending (spending the splice tx) and after it locked. Both are punished, since secrets are stored by commitment number.

@kwsantiago: also af1d1fa from the spec review on bolts#17: a peer that sets 266 in init is now refused even when we offer the option, matching Lightning Fork. And the branch is rebased onto blake2b-unified at e48ea5b to clear the conflicts; ours now come after the base's new migration, hsmd message and rewind test.

On that base: test_independent_secrets.py passes both ways (26 v1, 17 dual-funded), all 61 of test_splicing.py, the unit tests and check-hsm-versions, and all nine interop scenarios against Lightning Fork 9b0d0b3.

@paulscode

Copy link
Copy Markdown

Thanks Dave, both look right, and the breach tests on the splice's funding are good to have. I built 8aebea0 and ran test_independent_secrets.py (v1: 26 passed; dual-funded: 17 passed) and the two db-rewind tests in test_splicing.py; all pass. The rebase only changed the three commits with conflicts, and the migration order is safe since the base migration shipped in .8 and these are in no release. A few small things:

  1. The docs and schemas say the options were added in v26.06.9-blake2b.8 (doc/lightningd-config.5.md, listconfigs.json, listpeerchannels.json), but .8 was tagged without this PR, so that'll want to be the next release.
  2. If the cap is reached while a splice is pending and the splice is then aborted (tx_abort before signatures), nothing re-checks the cap, so the close waits for the next revocation. Nothing grows meanwhile, since a quiet peer adds no secrets, but calling check_independent_secrets_cap() at the end of handle_splice_abort would start it right away. Related: a splice that never confirms keeps the channel at the cap with no mutual close and no 48 h timer; the backstop still bounds it.
  3. The 10% backstop can force close without a mutual close having been tried, during a lease, a pending splice or after the 48 h timeout. That seems intended, but worth a line in the docs, along with the two new behaviours: the close waiting for a pending splice to lock, and a peer that sets 266 in init being refused.
  4. --max-independent-secrets accepts values below 1,000,000, which the spec now says a limit SHOULD NOT go under; with a small cap the 10% margin can come down to a single secret, so an honest peer could be force closed. A floor or a warning would match the text.
  5. --dev-independent-secrets-sender opens and accepts plain channels with peers that don't offer 267, which then fail at the second revocation. The spec says a node whose secrets don't come from the seed MUST NOT accept a channel without the add-on. It's dev only, but refusing those channel types, with a separate knob for the negative test that relies on it, would keep the dev mode within the spec.
  6. Nit: test_independent_secrets_penalty_spliced could also check that the pending case's old commitment spends the splice tx (decoderawtransaction(old_tx)['vin'][0]['txid']) and compare the penalty amount with the cheater's to_local.

@davelund110

Copy link
Copy Markdown
Author

Thanks Paul, all done in five new commits on top:

  • 1d596d0: an aborted splice now re-checks the cap once channeld restarts, so the close starts right away. The docs say a splice that never confirms is bounded only by the backstop.
  • 37582a1: a max-independent-secrets below 1,000,000 is logged as a warning at startup. I didn't make it a floor, since the tests use small limits, and the spec only says SHOULD.
  • 2055f81: with --dev-independent-secrets-sender, fundchannel_start and openchannel_init refuse a channel type without the add-on, and so do both opening hooks' offers. --dev-independent-secrets-any-channel lifts that for the test that shows such a channel failing. There's a new test for both directions.
  • 0e96033: test_independent_secrets_penalty_spliced checks that the breached commitment spends the splice tx, and that the penalty's input is the cheater's to_local, matching its balance less the commitment fee and anchors.
  • 383eace: docs: the backstop closing without a mutual close (lease, pending splice, after 48 h), the splice wait, 266 in init, and "added in" is now v26.06.9-blake2b.9.

With them: check-units, test_independent_secrets.py both ways (27 v1, 18 dual-funded), all 61 of test_splicing.py, and all nine interop scenarios against Lightning Fork's merged blake2b.

@paulscode

Copy link
Copy Markdown

Thanks Dave, the five commits look right, and test_independent_secrets.py passes here in both modes (v1: 27 passed; dual-funded: 18 passed). Disabling the two accept-side refusals of 2055f81 makes the new sender test fail in both, so it covers them.

One thing to update: v26.06.9-blake2b.9 was tagged today (bf4e4ea) without this PR, so the "added in v26.06.9-blake2b.9" labels in the docs and schemas want to become .10, and the branch a rebase onto .9.

Smaller, optional:

  • The docs say the cap's close starts "straight away if the splice is aborted". For an RBF abort only the newest candidate goes, and the signed original keeps the close waiting until it locks, which is right, but the sentence reads otherwise. Also, the re-check in handle_splice_abort has no test of its own.
  • The dev sender's refusal in fundchannel_start comes after command_check_only, so check fundchannel_start doesn't report it.

Proposed in a draft bLIP: a channel type add-on under
which a node doesn't require its peer's per-commitment secrets to come from
a shachain, so the peer can generate them k-of-n.  This adds the bits (even
in the channel type, odd in init and node_announcement), their name, the
dependency on option_channel_type, and accepts the add-on as a variant of
either basic channel type when we offer it.  Nothing offers it yet.
…n one place

How many per-commitment secrets the peer has revealed, and the last of
them, come from the shachain in four places: the revocation check, channeld's
init and channel_reestablish for a closing or long-closed channel.  Put them
behind channel_their_revocations() and channel_their_last_secret() (and
closed_channel_their_last_secret() for closed ones), which on an
option_independent_secrets channel whose secrets outgrew the shachain will
read a count and the latest secret instead.  No change for shachain channels.
…hain

On a channel with option_independent_secrets the peer's per-commitment
secrets needn't be a shachain.  They still go in it for as long as they fit,
as they always do from a peer which uses one: then nothing changes, down to
the static channel backup, which carries the shachain and so can still
punish.  From the first secret which doesn't fit, peer_got_revoke() stores
that one and every later one in a new table, channel_revocation_secrets, in
the same db transaction as the rest of the revocation.  channeld has already
checked that each secret generates the point of the commitment it revokes.

Loading a channel, open or closed, reads whether its secrets outgrew the
shachain, and if so how many there are and the latest, for
channel_reestablish.  Once the channel is closed and resolved,
wallet_channel_close() keeps only the latest: nothing can spend the funding
output any more.  The migration's revert refuses while a channel which hasn't
closed uses the add-on, since an older version would expect the next secret
to extend a shachain and fail the channel.

test_splice_stored_feerate_repaired_on_upgrade re-runs the last migrations,
so it now reaches back one more, and drops the new table first.
onchaind derives a revoked commitment's secret from the shachain it is
given, which on an option_independent_secrets channel may hold only the
secrets before the first which didn't fit.  So lightningd unmasks the number
of the commitment that spent the funding output, exactly as onchaind does,
and if the peer revoked it and its secret is in the db, passes that secret
in onchaind_init, along with the number of secrets held, which onchaind now
takes from lightningd rather than counting the shachain.  A mutual close or
one of our own commitments can unmask to a revoked number too: onchaind
recognizes those first and ignores the secret.
…nel types

listpeerchannels, listclosedchannels and the opening commands report the
channel type's names, which are a closed enum in the schemas, cln-rpc's
ChannelTypeName and cln-grpc's primitives.proto: add the new one in each, so
a channel with the add-on validates and deserializes.  primitives_pb2.py is
regenerated with protoc 31.1, which reproduces the previous file exactly.
With --independent-secrets we signal the optional bit, and every new channel
with a peer which signals it too uses the add-on, including a channel type
the caller names (it changes nothing but what the receiver accepts).
Channels with other peers open as before.  It is off by default: the
receiver of such secrets stores each one, so an operator chooses to.

It can't be used with an external signer, an hsmd replaced with
--subdaemon such as VLS, which checks the peer's secrets against a shachain:
lightningd refuses to start with both, or with an external signer while any
channel with the add-on is open or being resolved, since a channel keeps it
after the option is removed.  Like option_blake2b and option_unified_sigs,
it is not available on Liquid.

Changelog-Added: Protocol: `option_independent_secrets` (266/267): a peer's per-commitment secrets need not come from a shachain, so it can generate them k-of-n; `--independent-secrets` offers it (off by default, and not with an external signer).
To exercise a peer's support for option_independent_secrets, this developer
option makes hsmd derive our own per-commitment secrets as
HMAC-SHA256(shaseed, "independent-per-commitment-secret" || n || counter),
the same function as Lightning Fork's dev option, instead of from a shachain.
Like the shachain it needs nothing but the seed, so every secret can be
produced again.  It applies to every channel, so lightningd refuses to start
with it changed while any channel is open or being resolved, and it needs
--independent-secrets, since only a peer with the add-on accepts them.
Negotiation, which only --independent-secrets offers, and the options it
refuses (an external signer, also while a channel with the add-on is open,
and the dev sender without it); that a channel keeps the add-on, storing and
punishing, after a restart without the option; that a peer which uses a
shachain costs nothing, including to a static backup
(test_emergencyrecoverpenaltytxn now also runs with the add-on); storage across restarts and reconnections; that a
shachain channel refuses the dev sender's secrets (which shows the other
tests really give the receiver unrelated ones); the startup guard on the dev
option; penalties from the db and from the shachain part, with and without
an HTLC, and across restarts of onchaind; the peer's own unilateral close;
pruning once the channel is forgotten, and channel_reestablish for it
afterwards.  Most run with both v1 and v2 opens.
What the add-on is for, what this node does with it, how to turn it on,
its limits (static channel backups, external signers, downgrades) and the
dev option.
On a channel with option_independent_secrets, listpeerchannels now shows
independent_secrets_stored: how many of the peer's per-commitment secrets
we store one by one, from the first which didn't fit the shachain.  It stays
0 with a peer which uses one, and it is how an operator sees what the
add-on costs on each channel.

Changelog-Added: JSON-RPC: `listpeerchannels` shows `independent_secrets_stored` on channels with `option_independent_secrets`.
On a channel with option_independent_secrets, every update adds a secret we
store one by one once they outgrow the shachain, and nothing else bounds
them while the channel is open.  --max-independent-secrets (default
1,000,000: 32MB of secrets, about 60MB in sqlite3; 0 for no limit)
bounds them: at 90% we log a warning, and at the limit we start a mutual
close, as `close` would, sending P2WPKH if the peer can't take our default
P2TR address.  After `shutdown` the peer only needs as many more as it takes
to settle the HTLCs in flight, but it can keep a closing channel updating
with update_fee, so once we store 10% more than the limit (at least one
more) we close the channel unilaterally.  Like `close`, we keep a lease we
sold, and close at the first update after it expires.

Changelog-Added: Config: `max-independent-secrets` closes a channel with `option_independent_secrets` once it stores that many of the peer's secrets (default 1000000).
… close

Three fixes to max-independent-secrets, from review:

- A channel the peer leased from us skipped the backstop as well as the
  mutual close, so until the lease expired a peer could make us store
  its secrets without limit. We still don't start a mutual close before
  the lease expires, but the 10% backstop now applies: our funds stay
  locked until then however the channel closes.
- The mutual close started at the cap had no timeout, so a peer could
  leave it unfinished for ever. After 48 hours, as `close` waits by
  default, we now close unilaterally. The timer isn't saved, so at
  startup it is armed again for a channel that is closing and over the
  cap. --dev-independent-secrets-close-timeout shortens it for tests.
- The 10% now counts from where we first saw the channel at or over the
  cap, so a cap lowered below what a channel already stores starts a
  mutual close at its next update instead of failing the channel at the
  one after.

Also point the docs and comments at lightning-blake2b/bolts#17, where
the spec now lives, instead of the draft bLIP.
From review: a restart reset the 10% backstop and the 48-hour timeout
of the mutual close started at max-independent-secrets, and at startup
the timeout was armed for any closing channel over the cap, including a
close we didn't start.

- How many secrets the channel stored when we started the close is now
  saved, in a new channels column, and the backstop counts from there.
  Without such a close it counts from the cap itself, so nothing resets
  on a restart.
- At startup the timeout is armed only for a close we started, with
  whatever is left of its time since the channel moved to
  CHANNELD_SHUTTING_DOWN, as its state changes record.
…t in init

The spec (lightning-blake2b/bolts#17) now says a receiver of bit 266 in
init MUST close the connection: the even bit is only valid in channel_type.
A node without --independent-secrets already did, since the bit was unknown
to it, but one that offers the option knows the bit and kept the peer.
Lightning Fork refuses such a peer either way, so the two now match.
channel_start_mutual_close() accepted CHANNELD_AWAITING_SPLICE, so reaching
max-independent-secrets while a splice was pending sent shutdown before the
splice locked, which BOLT 2 doesn't allow. close does the same today, but
here nobody asked for a close.

It now refuses a channel with a pending splice, and the cap logs that it
will close once the splice locks; handle_peer_splice_locked() starts the
close then. The 10% backstop still bounds what we store meanwhile.
The peer's secrets are stored by commitment number, and one secret revokes
that number on every funding output, so a revoked commitment on a splice's
funding output is punished like any other. Test it with a cheater that
sends independent secrets: its revoked commitment on the new funding,
broadcast while the splice is pending (spending the splice transaction),
and after the splice locked.
If max-independent-secrets is reached while a splice is pending, the
mutual close waits for the splice to lock.  If the splice is aborted
instead, nothing checked the cap again until the next revocation.  Check
it once channeld restarts after the abort.
The 10% backstop can close a channel unilaterally without a mutual close
having been tried (during a lease, while a splice is pending, or after
the 48 hours); the close waits for a pending splice to lock; a peer that
sets 266 in init is refused; a limit below 1,000,000 is logged.

v26.06.9-blake2b.8 was tagged without this, so the options and the new
field are added in the next release, .9.
At the backstop, check_independent_secrets_cap() fails the channel.  On a
channel whose funding we withheld, drop_to_chain() then forgets it, which
frees it, yet peer_got_revoke() went on to save it and to fill the
commitment_revocation hook's payload from it: a use-after-free, which
valgrind reports and which can crash lightningd or write the forgotten
channel back.  Check the cap after everything else instead.

The new test opens a withheld zeroconf channel with a peer using the dev
sender and a cap of 10, and settles a payment past the backstop: the
channel is forgotten, stays forgotten across a restart, and valgrind
reports nothing.  Under valgrind it fails without this change.
…ered cap's slack

Two problems from a security review of the cap:

- After a restart, the timeout of the mutual close we started was re-armed
  with 1 second if lightningd had been down for longer than the whole
  timeout, or if the clock had gone back (time_between() of a later
  timestamp).  Timers already run during startup, before connectd, so the
  channel was closed unilaterally before the peer could reconnect.  Now a
  clock that has gone back counts as no time, and the peer gets a tenth of
  the timeout, up to 10 minutes (so 10 minutes by default).  Whether the
  close is ours now comes from its state change, so
  independent_secrets_close_at can be used for the backstop alone.

- When the limit was lowered below what a channel stores and we couldn't
  start a mutual close (a lease we sold, a pending splice, a close someone
  else started, or one that couldn't start), the backstop counted from the
  new limit and failed the channel at its next update.  It now counts from
  where the channel first reached the limit, saved likewise, or from the
  limit if that was raised since.

The docs also say that update_fee can only keep a closing channel updating
while HTLCs remain.

New tests: a restart after the timeout ran out, one with the clock gone
back, and a limit lowered while a splice is pending.  All three fail
without this change.  The splice tests' payment helper now times out
rather than hanging when its channel closes.
With --dev-independent-secrets-sender, fundchannel_start refused a
channel type without option_independent_secrets only after its
command_check_only(), so `check fundchannel_start` said it would
succeed.  Derive the type and refuse it first, as openchannel_init
already does.  The test checks both commands with `check`.
handle_splice_abort() checks the cap again, but nothing tested it.  A
splice aborted with the channel past a limit lowered while we were down
now has to start the close without another revocation: the test fails
without that check.  And an aborted RBF attempt drops only the attempt,
so the close waiting at the cap keeps waiting for the splice under it,
and starts once that locks.
v26.06.9-blake2b.9 was tagged without option_independent_secrets, so the
options and the listpeerchannels field come in the next release.  And an
aborted RBF attempt leaves the splice it would have replaced pending, so
the cap's close keeps waiting for that one: say so, rather than that an
aborted splice always starts the close.
@davelund110
davelund110 force-pushed the independent-secrets-blip branch from 383eace to d4d1391 Compare October 24, 2026 13:50
@davelund110

Copy link
Copy Markdown
Author

Thanks Paul. The branch is rebased onto v26.06.9-blake2b.9 (bf4e4ea), without conflicts, and your points are in three new commits:

  • d4d1391: "added in" is now v26.06.9-blake2b.10 in the docs and schemas. The docs also say that an aborted RBF attempt keeps the close waiting for the splice under it, rather than that an aborted splice always starts it.
  • d67c652: two tests for the abort path. One aborts a splice with the channel past a limit that was lowered while the node was down, and checks that the close starts without another revocation; it fails without the re-check in handle_splice_abort. The other aborts an RBF attempt at the cap, and checks that the close waits for the splice under it and starts once that locks.
  • 70fba63: with the dev sender, fundchannel_start now derives the channel type and refuses one without the add-on before command_check_only, so check fundchannel_start reports it. The sender test checks check of fundchannel_start, and of openchannel_init with dual-funded opens.

Before those are two commits from a security review of the whole series, read as an attacker would. It found nothing that could lose funds, but three things in the cap code:

  • 4585d53: a use-after-free. At the backstop, channel_fail_permanent() on a channel whose funding we withheld (zeroconf with withhold=true) forgets it, which frees it, and peer_got_revoke() went on to save it. The cap check now runs last. The new test crashes lightningd under valgrind without the fix, and is clean with it.
  • ed0d136: after a restart, the cap's close timeout was re-armed with 1 second if we'd been down longer than the timeout, or if the clock had gone back, so the channel closed before the peer could reconnect. It now gets a tenth of the timeout, up to 10 minutes (so 10 minutes by default), and a clock gone back counts as no time. Whether the close is ours now comes from its state change.
  • ed0d136: a cap lowered below what a leased, splicing or already-closing channel stores failed it at its next update. The 10% now counts from where the channel first reached the cap, saved like the rest.

The new tests for the fixes fail without them. With everything, on the new base: check-units and check-hsm-versions, test_independent_secrets.py both ways (33 v1, 21 dual-funded), all 62 of test_splicing.py, the 11 cap and dev sender tests under valgrind with no errors, and all nine interop scenarios against Lightning Fork's blake2b at paulscode/lightning-fork@b13cb18.

@paulscode

Copy link
Copy Markdown

Thanks Dave. I went through the five new commits and found nothing more to fix. The rebase onto .9 changed none of the earlier commits, and with each fix reverted its test fails: the cap check moved back above the save gives valgrind's invalid read in wallet_channel_save under test_independent_secrets_cap_withheld, the old re-arm fails both restart_late cases, and dropping the saved count fails cap_lowered_during_splice. Nothing touches the channel after the cap check on any of its three callers.

Here: test_independent_secrets.py 33 passed (v1) and 21 (dual-funded); the cap tests under valgrind 13 (v1) and 6 (dual-funded) with no errors. Our lab scenarios against this head, with our next release candidate, pass as before apart from one that now fails in setup (a reconnect during fundchannel), which fails the same way with the previous head, so it's our lab, not this. All nine of your interop scenarios pass against Lightning Fork's blake2b.

Two small notes:

  • The comment on the independent_secrets_close_at migration (wallet/migrations.c ~1191) still says it's set only when we started a mutual close; it's now where the channel first reached the cap.
  • After a restart the close timeout is re-armed even if max-independent-secrets has since been set to 0. That matches close's own timeout, so a line in the docs is enough.

peer_got_revoke() started the cap's close after replying to channeld.
A peer that sent stfu and splice_init right after the revoke_and_ack
that reached max-independent-secrets could have channeld accept the
splice before it learned of the shutdown.  channeld then held the
shutdown while quiescent and sent it once the splice was signed but
unlocked, which BOLT 2 forbids; lightningd went from
CHANNELD_SHUTTING_DOWN back to CHANNELD_AWAITING_SPLICE, the mutual
close spent the old funding output and conflicted with the splice, and
the channel stayed in CLOSINGD_COMPLETE until a restart.

Now peer_got_revoke() starts the close before it replies, so channeld,
which waits for that reply, queues the shutdown request and handles it
before anything else from the peer.  The backstop, which can free the
channel, still runs last.

And channeld stops splicing once either side has sent shutdown, or
lightningd has asked it to send one: an incoming splice_init or
tx_init_rbf gets a warning and the connection is closed, as BOLT 2 asks
of a node that has received shutdown (we reestablish with ours); a
local splice_init is refused; and a local one still waiting for
quiescence is cancelled the same way.

test_independent_secrets_cap_refuses_splice has the peer start a splice
right after the revocation that reaches the cap: without this change
its splice is accepted; with it, the splice is refused and the close
completes on the old funding output.
…limit

From Paul's review of commits 22 to 26:

- The comment on the independent_secrets_close_at migration still said
  it records where the channel stood when we started our close.  Until
  then (a lease, a pending splice) it's where the channel first reached
  max-independent-secrets.
- After a restart, the timeout of a close we started at the limit is
  re-armed even if max-independent-secrets is now 0, as close's own
  timeout is.  The docs say so.
@davelund110

davelund110 commented Oct 24, 2026 •

Copy link
Copy Markdown
Author

Thanks Paul, and for reverting each fix to check its test. Both notes are in cbc4c55: the migration's comment now says the 10% counts from where the channel stood when our close started or, until then, where it first reached the cap; and the docs say a close's timeout stays if the limit is then set to 0, as close's does.

Before it is one more commit, d3e988f, from a second security review of the whole series. Its storage, reestablish, penalty and database half found nothing to fix (it also tried a reorg that swaps one revoked commitment for another: the second is punished). The cap half found one thing:

  • A splice started as the cap's close begins. A peer that sent stfu and splice_init right after the revoke_and_ack reaching the cap got its splice accepted: peer_got_revoke() started the close only after replying to channeld, so channeld learned of the shutdown afterwards, held it while quiescent, and sent it once the splice was signed but unlocked. lightningd went from CHANNELD_SHUTTING_DOWN back to CHANNELD_AWAITING_SPLICE, the mutual close spent the old funding output and conflicted with the splice, and the channel sat in CLOSINGD_COMPLETE until a restart. No funds were at risk: the node still handles a commitment on the splice's output.
  • d3e988f starts the cap's close before the reply, so channeld queues the shutdown request and handles it before the peer's next message; and channeld stops splicing once either side has sent shutdown or it has been asked to send one (a peer's splice_init or tx_init_rbf gets a warning and a disconnect, as BOLT 2 asks after shutdown; a local one is refused or cancelled). The new test fails without it.

The review also caught three claims in the description that were broader than the code: our latest commitment, not any of ours, is recognised before a secret is used; the 10% restarts when our close starts after a lease or splice wait, so storage stays below about 1.2x the cap rather than 1.1x; and the migration's revert only runs in tests, since lightning-downgrade refuses this fork's wallets. The description is updated.

@kwsantiago, three older splice issues turned up while reproducing it, none from this PR (they're also at the end of the description): close accepts CHANNELD_AWAITING_SPLICE and channeld then sends shutdown with the splice unlocked; after a mutual close, closed_inflight_splice_found() means to close unilaterally, but drop_to_chain() takes the splice for an already-seen unilateral close and broadcasts nothing, so the channel waits for a restart; and splice_init doesn't return if the peer disconnects after our splice.

With everything: check-units, test_independent_secrets.py both ways (34 v1, 22 dual-funded), all 65 of test_splicing.py and test_splicing_disconnect.py, the splice and backstop cap tests under valgrind with no errors, and all nine interop scenarios.

paulscode added a commit to paulscode/lightning-fork-lab that referenced this pull request Oct 24, 2026
…htning Fork and the CLN port

scenario-independent-secrets.sh runs each case on a BLAKE2b regtest of its
own (isec-knots) on the lab network: negotiation with each side on, off or
the previous release; normal operation with both ends sending non-shachain
secrets; a revoked state broadcast by either side and punished by the other,
before, during and after a splice; an lnd tower punishing for an offline
lnd; a peer setting 266 in init.

It needs the CLN port with privkeyio/lightning#48 (CLN_IMAGE) and lnd with
lightning-fork#7 (LND_IMAGE, and LND_INT_IMAGE, the same tree built with
the integration tag and SIGHASH_UNIFIED on); LND_REL_IMAGE is the previous
release. lnd runs with --nobootstrap: once the peer's node announcement is
in its graph the bootstrapper dials the peer again, and the peer drops the
connection it had along with a channel being opened on it.
@paulscode

Copy link
Copy Markdown

Thanks Dave. d3e988f fixes the revocation path, but the same race is still open when the cap's close starts at a splice lock. If the cap is reached while a splice is pending, the close waits for the lock, and handle_peer_splice_locked starts it after channeld has reported the lock and carried on without waiting for an answer. A peer that sends stfu and a new splice_init right after the lock can get it accepted before channeld hears about the shutdown.

I reproduced it with a probe that widens the window (a plugin delaying lightningd's database write for the lock by 4 s; normally the window is that write against one round trip to the peer): l1 sent splice_ack while already in CHANNELD_SHUTTING_DOWN, logged CHANNELD_SHUTTING_DOWN to CHANNELD_AWAITING_SPLICE and unexpected value CHANNELD_SHUTTING_DOWN, sent shutdown with the new splice unlocked, and the mutual close (which conflicts with the splice) was never broadcast; both nodes were still in CLOSINGD_COMPLETE 7 blocks later. No funds at risk, the same end state as the race you fixed. Either making the lock report wait for a reply and starting the close before it, as the revocation now does, or having channeld tx_abort a splice it is negotiating when a shutdown request arrives, would close it; the second also covers a user's close during a splice. I can send the probe test and plugin if useful.

On the new test: with only the ordering change reverted it still refuses the splice and completes the close, and fails only because the "deferred WIRE_CHANNELD_SEND_SHUTDOWN" log line is missing; with channeld's refusal removed and the ordering kept, the splice is accepted. So the refusal does the work and the ordering is extra safety, which the test could assert on behaviour rather than the log line.

Everything else checks out: the early cap check never frees the channel (4585d53's ordering holds), and HTLC failures after the shutdown request are fine under BOLT 2. Here: test_independent_secrets.py 34 (v1) and 22 (dual-funded), test_splicing.py and test_splicing_disconnect.py 65, the cap and splice tests under valgrind clean, our lab scenarios all pass, and your nine interop scenarios pass against Lightning Fork's blake2b. With Lightning Fork splicing into a channel as it reaches the cap, CLN refused with "Splice refused: the channel is closing", Lightning Fork aborted the splice, and the channel closed cooperatively in both timings we tried.

…t a splice lock

d3e988f started the cap's close before replying to the revocation that
reaches max-independent-secrets, but when the cap is reached while a
splice is pending, the close starts at the lock instead, in
handle_peer_splice_locked(), and channeld didn't wait for that: it told
lightningd of the lock and carried on.  A peer that sent stfu and
splice_init right after the lock could have the new splice accepted
before channeld learned of the shutdown.  channeld then sent shutdown
with that splice unlocked, which BOLT 2 forbids; lightningd went from
CHANNELD_SHUTTING_DOWN back to CHANNELD_AWAITING_SPLICE; the mutual close
conflicted with the splice; and the channel stayed in CLOSINGD_COMPLETE
until a restart.  A slow database write at the lock (a db_write plugin,
say) makes the window wide.

Now channeld waits for a reply to channeld_got_splice_locked, and
lightningd starts the close before it replies, so channeld handles the
shutdown request before anything else from the peer, and refuses the
splice.  The backstop, which can free the channel, runs after the reply.
… what happens

test_independent_secrets_cap_refuses_splice_at_lock has the peer start a
new splice as soon as the splice the cap's close waited for locks, while
a db_write plugin (tests/plugins/dbwrite_stall.py) holds lightningd at the
lock for 8 seconds.  Without the previous commit l1 sends splice_ack;
with it, the splice is refused and the close spends the funding that
locked.

test_independent_secrets_cap_refuses_splice now checks what happens (the
splice refused, no splice_ack, the close spending the funding we had)
rather than the order of channeld's log lines, as Paul suggested: it's
channeld's refusal that stops the splice there.
…eestablish

Messages a peer sends before its channel_reestablish are stashed, and
then handled in one loop, without first handling requests lightningd
queued meanwhile, as the main loop does.  So a peer could send the
revoke_and_ack that reaches max-independent-secrets, then stfu and
splice_init, all before its channel_reestablish: lightningd asks for the
shutdown while the revocation is handled, but channeld would accept the
splice before it got to that request.  Now it handles lightningd's queued
requests before each stashed message.
Every penalty test had the node that stores the secrets as the channel's
accepter.  lightningd unmasks a revoked commitment's number with the
opener's payment basepoint first, which only a channel we opened tells
apart: with that order broken, the existing tests only noticed through a
log line on the cheating node.  test_independent_secrets_penalty_we_opened
swaps the roles, and fails directly.
check-source-bolt takes a comment that starts with "BOLT" for a quote
from the BOLTs, and wants "BOLT #n:" with the quoted text.  Three
comments in this series start "BOLT 2 doesn't allow ..." or "BOLT 3
says ...", which made check-source fail.  Reword them.
run-wallet includes peer_htlcs.c, whose cap code calls
channel_start_mutual_close().  gcc's builds drop the unused reference,
but clang's (the macOS CI job, with --disable-compat) doesn't, and the
link fails.  Add the stub update-mocks would generate.
@davelund110

Copy link
Copy Markdown
Author

Thanks Paul, and for reproducing it. A review here found the same race, and the fix is in four new commits, plus two for the first CI run on this PR.

f72e90c: channeld now waits for lightningd at a splice lock (a new reply, channeld_got_splice_locked_reply), and lightningd starts the cap's close before it replies, as the revocation does since d3e988f. The backstop still runs after the reply. I went with this rather than a tx_abort when a shutdown request arrives: it's the same shape as d3e988f, and the new splice is refused before any negotiation starts. A tx_abort would also help a user's close during a splice, the older issue, but it touches splice negotiation beyond this PR, so I'd leave it for a separate one.

231c6a6: the same review found a related path by reading. Messages a peer sends before its channel_reestablish are handled in one loop, without first handling requests lightningd queued meanwhile, so the revoke_and_ack that reaches the cap, then stfu and splice_init, all sent before the reestablish, would get past the refusal. channeld now handles queued requests before each of those messages, as its main loop does. I couldn't test this one: it needs a peer that sends them out of order.

On the tests:

  • 4260774: test_independent_secrets_cap_refuses_splice now checks what happens (the splice refused, no splice_ack, the close spending the funding we had) rather than the log line. And the new test_independent_secrets_cap_refuses_splice_at_lock uses a small db_write plugin (tests/plugins/dbwrite_stall.py) to hold lightningd for 8 s at the lock while the peer starts a new splice, much like your probe. On cbc4c55 l1 sends splice_ack; with f72e90c the splice is refused and the close spends the funding that locked.
  • de45089: test_independent_secrets_penalty_we_opened. Every penalty test had the node storing the secrets as the accepter, so nothing checked that the commitment number is unmasked right on a channel we opened. With that order broken on purpose, the existing tests only noticed through a log line on the cheating node; this test fails directly.

With everything: test_independent_secrets.py passes both ways (36 with v1 opens, 24 dual-funded), all of test_splicing.py and test_splicing_disconnect.py (65), check-units, the changed cap and splice tests under valgrind with no errors, and all nine interop scenarios against Lightning Fork's blake2b at b13cb18.

The first CI run on this PR also turned up two older problems, both fixed: 44c4d93 rewords three comments that started with "BOLT 2" or "BOLT 3", which check-source-bolt takes for quotes from the spec; and 03c4d78 adds the run-wallet stub for channel_start_mutual_close, which the macOS (clang) build needs to link. Both pass here, check-source-bolt against the BOLTs and the unit tests built with clang and --disable-compat.

A plugin's getmanifest featurebits were merged into our features with
only a check that a bit wasn't already set. So a static plugin could set
bit 267 and turn option_independent_secrets on without
--independent-secrets (listconfigs still showing it off, and the Liquid
refusal skipped), or set the even bit 266, which BOLT 3 forbids in init
and node_announcement. Refuse a plugin that sets either bit, in any
place, as lnd refuses the equivalent custom features.
…needs both peers

fundchannel_start and openchannel_init add bit 266 to a channel_type
the caller names when both peers offer the feature, but sent a named
type with 266 to a peer that doesn't offer 267, which BOLT 2 says the
opener SHOULD NOT do (a type with a feature that wasn't negotiated). As
fundee, openingd and dualopend accepted 266 from such a peer. Refuse
both, as lnd does. --dev-any-channel-type still allows any type.
…sing

BOLT 2 asks the splice_init receiver to warn and close the connection
if it has received shutdown, and to refuse with tx_abort otherwise. When
only we are closing we warn and reconnect too: after a tx_abort channeld
restarts on the same connection, and the new channeld can't tell whether
our shutdown went out, so it could send it twice (lnd disconnects on a
second shutdown) or never, while reestablish retransmits it. Say so in
the comment and the doc rather than change it.
The docs said that, like close's own timeout, the timeout of a close
started at max-independent-secrets stays if the limit is set to 0. But
close's timeout belongs to the command and a restart forgets it, while
the cap's is armed again at startup (and changing the limit takes a
restart).
@davelund110

Copy link
Copy Markdown
Author

Four small commits for the remaining low items from the review. None of them could lose funds:

  • d5c9746 lightningd: refuse option_independent_secrets bits in plugin featurebits. A static plugin could set 267 and turn the option on without --independent-secrets, or set 266 in init and node_announcement, which BOLT 3 forbids. lnd already refuses the equivalent. Test: test_independent_secrets_plugin_featurebits, for both bits, with and without the option.

  • 8d067aa a channel_type with 266 now needs both peers to offer the feature. fundchannel_start and openchannel_init refuse a named type with 266 for a peer without 267 (BOLT 2's SHOULD NOT for a feature that wasn't negotiated), and openingd and dualopend refuse one from such a peer, as lnd does. --dev-any-channel-type still allows anything. Tests: test_independent_secrets_negotiated (changed) and test_independent_secrets_refused_unless_both_offer.

  • dbef153 the splice refusal when only we are closing stays a warning and a reconnect, rather than the tx_abort BOLT 2 asks for when the peer hasn't sent shutdown. After a tx_abort, channeld restarts on the same connection and can't tell whether our shutdown already went out, so it could send it twice (lnd disconnects on a second shutdown) or never; reestablish retransmits it. The comment and the doc now say so.

  • 2a73f66 doc fix: the cap's close timeout survives a restart, unlike close's own.

test_independent_secrets passes (v1 and dual-funded), as do the splicing tests and the plugin feature tests.

listpeerchannels gained independent_secrets_stored, so the sql plugin's
peerchannels table has the column too (doc/schemas/sql.json already lists
it), but test_sql's expected schema didn't.
The cap's close timer is armed before plugins are configured, and
lightningd runs its event loop while they are, so on a slow start (under
valgrind) a timeout with a few seconds left fires before "Server
started", where the log search after restart() begins.
test_independent_secrets_cap_close_restart and _restart_late[overdue]
then never saw the line. Look for it from the restart instead.
test_independent_secrets_cap_refuses_splice_at_lock stalls lightningd
with a db_write hook, and only sqlite3 reports writes to that hook, so
with postgres the stall never comes. Skip it there, as test_db_hook is.
@davelund110

Copy link
Copy Markdown
Author

The first full CI run turned up three problems in the new tests, not in the code. All are fixed:

  • bceeab4: test_sql now expects the new independent_secrets_stored column in peerchannels.
  • 98f277a: the restart tests look for the cap close's timeout log from where the restart began, since it can be logged while lightningd is still starting, before the test waits for it.
  • a8cb2c0: test_independent_secrets_cap_refuses_splice_at_lock is skipped with postgres, since only sqlite3 has the db_write hook it uses to stall the splice lock.

All checks pass now. @kwsantiago @paulscode this is ready for another look when you have time.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants