Skip to content

multi: add option_independent_secrets channel type - #7

Merged
paulscode merged 24 commits into
paulscode:blake2bfrom
davelund110:independent-secrets-blip
Oct 22, 2026
Merged

paulscode merged 24 commits into
paulscode:blake2bfrom
davelund110:independent-secrets-blip

Conversation

@davelund110

@davelund110 davelund110 commented Oct 17, 2026 •

Copy link
Copy Markdown

What

This PR is the receiver side of option_independent_secrets, which is specified in the fork's BOLTs ([lightning-blake2b/bolts#17](lightning-blake2b/bolts#17)). The CLN fork has it too ([PR #48](privkeyio/lightning#48)); the two were tested against each other.

  • What changes on a channel: a channel with the add-on stores every per-commitment secret it receives. It no longer requires the secrets to come from a shachain.
  • Where the secret is stored: each secret goes into the revocation log entry of the commitment it revokes.
  • How punishment works: a breach is punished from that log entry.
  • Off by default, for now: protocol.independent-secrets turns it on. As the maintainer proposed, the release that includes it ships it off, and can already read the new store and database version 39; a later release turns it on by default, so a rollback from there lands on a release that reads 39 and no downgrade tool is needed.
  • When it's used: a channel type gets the add-on only when both peers signal the feature (bit 267). It's carried as bit 266 in channel_type, which lnd sends on every open since 0.21.4.
  • Visible over RPC: listchannels, pendingchannels and closedchannels report independent_secrets per channel, and the channel acceptor gets wants_independent_secrets.
  • Downgrade marker: once the database stores a channel with the add-on, it moves to version 39, so an older release refuses to start with a clear error instead of failing on that channel. A node that never has such a channel, not even a pending one, can still downgrade.

Channels without the bit behave exactly as before. They use the same store type, write the same bytes on disk, and run the same checks.

Why

With 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 give that seed to the channel peer.
  • The peer then holds the secret for the node's current commitment.
  • The peer can sweep the whole channel through the revocation path once that commitment is broadcast.

Independent secrets let a node generate and reveal its secrets jointly.

This PR doesn't add a threshold signer. It adds what every peer has to support first: accepting secrets that aren't shachain-derived. For testing, it also adds a dev-only way for lnd to send such secrets.

Commits

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

Core (1–8)

  1. shachain: add IndependentStore for non-shachain secrets.
  2. channeldb: decode either revocation store kind (channel and migration codecs).
  3. channeldb: persist independent per-commitment secrets in the revocation log.
  4. lnwallet: accept these secrets, and punish breaches with them.
  5. contractcourt: test breach detection with independent secrets.
  6. lnwire+feature: add the feature bits and their dependency.
  7. funding: negotiate the channel type add-on.
  8. multi: signal it by default, with an opt-out flag (commit 20 turns it off by default).

Coverage, visibility and testing tools (9–16)

  1. chanbackup: test that backups ignore the add-on.
  2. wtclient: end-to-end test of the watchtower's justice transaction.
  3. lnrpc: report independent_secrets per channel.
  4. shachain: add IndependentProducer, a KDF secret generator.
  5. multi: add --dev.independent-secrets-sender, available in integration builds only.
  6. htlcswitch: run the link retransmission tests with independent secrets.
  7. itest: add a two-node test. It doesn't run yet; see Tests below.
  8. docs: docs/independent-secrets.md.

Splicing (17)

  1. channeldb, lnwallet: keep the secret for splices' revocation logs, so a revoked commitment on a splice's funding output can be punished.

Downgrade marker (18)

  1. channeldb: mark the database, as version 39, once it stores a channel with the add-on.

Review fixes (19)

  1. channeldb: pick the database version marks by number, so the next migration appended after them still runs; correct the docs on what marks the database.

Maintainer review (20–21)

  1. multi: turn it off by default, with protocol.independent-secrets to turn it on; the dev sender now needs that option; the feature bit comment and docs point to bolts#17 instead of a draft bLIP.
  2. chanacceptor, lnrpc: report the add-on to the channel acceptor as wants_independent_secrets.

Maintainer's second pass (22–24)

  1. feature, peer, funding: refuse custom init/nodeann bits that lnd defines, refuse a peer that sets 266 in init, and negotiate on bit 267 itself.
  2. multi: mark the database by UsesIndependentSecrets(), a plainer error when a revoked state has no secret, a commitment point check in the splice breach test, and independent_secrets in closedchannels.
  3. docs, lnwire, itest: review nits.

How

shachain

  • IndependentStore implements shachain.Store. It keeps a count plus the two most recent secrets.
    • The last secret is what channel_reestablish sends.
    • The one before it covers a reestablish built inside ReceiveRevocation, between AddNextEntry and advancing the remote commitment.
    • Older lookups return ErrSecretNotRetained.
  • The encoding starts with the marker byte 0xff. An encoded RevocationStore starts with its bucket count, which is at most 48, so the two can't be confused.
  • NewStoreFromBytes decodes either kind and consumes exactly its own bytes.
  • IndependentProducer, used only by the dev sender:
    • The secret at index i is HMAC-SHA256(root, tag || i || counter). The counter is bumped in the negligible case that the result isn't a valid key.
    • It encodes the same 32-byte root as RevocationProducer, so nothing new is stored, and taproot nonces derived from the root don't change.

channeldb

  • IndependentSecretsBit is 1 << 40, far above upstream lnd's allocations, so a rebase onto upstream can't hand it another meaning.
  • NewRemoteSecretStore(chanType) picks the store for a channel type.
  • OpenChannel.UsesIndependentSecrets() returns true if either the bit or the store type says so. A disagreement between the two then still finds its secrets in the log.
  • RevocationLog has an optional CommitSecret record. It uses TLV type 65537, a high number chosen to avoid future upstream records.
    • AdvanceCommitChainTail writes this record in the same transaction that moves the commitment into the log.
    • The secret written must be the newest one in the store. If the store is out of step with the commitment chain, the write is refused rather than answered with the secret before it.
  • ChanSyncMsg falls back to the revocation log if the in-memory store no longer holds the secret it needs. A copy of the channel that is refreshed in two steps can run into this.
  • Decoders switched to NewStoreFromBytes:
    • the channel codec;
    • the migration_01_to_11 codec, which is needed: the optional revocation-log migration (migration30) uses it to decode the revocation state of every channel, and it failed when an independent-secrets channel was present;
    • the migration21 codec, for consistency.
  • LocalIndependentSecretsBit (1 << 41) is a local bit, used only by the dev sender. When the channel is loaded, it rebuilds the IndependentProducer from the stored root. A channel type with this bit but without the add-on fails to load.

lnwallet

  • ReceiveRevocation now checks that the secret generates the expected point before storing it. On these channels, that check is all that ties a secret to the commitment it revokes.
    • It also rejects a secret that isn't a valid key as sent (0 < s < n). BOLT 2 already requires a valid secret key.
  • NewBreachRetribution reads the secret from the revocation log on these channels, and from the shachain store otherwise.
    • The watchtower client's retribution builder loads the channel from the database too, so it takes the same path.
  • InitFundingReserveMsg.IndependentSecrets: the reservation sets the bit, and the wallet creates the matching store.
  • Config.IndependentSecretsSender is set only by the dev option. The wallet then uses an IndependentProducer for new channels with the add-on, and derives the taproot nonce producer from it, exactly as it is derived after a restart.

funding

  • Add-on negotiation mirrors option_unified_sigs:
    • The add-on is proposed when both peers signal it.
    • It is removed before the commitment type is matched.
    • A peer that doesn't signal it refuses it in explicit negotiation.
    • It is allowed on taproot. (A node that signals option_unified_sigs, as every release build does, opens no taproot channels yet, because the unified bit isn't defined for MuSig2 signatures.)
  • The funder refuses an accept_channel whose type differs from the one it proposed, so a reply that adds the bit is refused.

splicing

  • While a splice is pending, the peer has a commitment on each funding output at the same number, and its revoked ones on a splice's funding go into that splice's own revocation log. A breach on that funding, and a watchtower backup of it, are built from the splice's entry.
  • One secret revokes the commitment with that number on every funding output, so AdvanceCommitChainTail passes it to applySpliceWrites, which stores it with each splice's entry at that number.
  • As a fallback for an entry without it, revokedCommitSecret takes the secret from the channel's own log at the same number, written in the same transaction.
  • Without this, a revoked commitment on a splice's funding output couldn't be punished on a channel with the add-on: a pending splice was enough, no reorg needed.

feature, lnwire, lncfg, server, chanacceptor, lnrpc

  • Bits 266/267 (independent-secrets). With protocol.independent-secrets, the optional bit is signalled in init and node_announcement. Negotiation checks bit 267 itself, and a peer that sets 266 in init, where the spec doesn't allow it, is refused. protocol.custom-init and custom-nodeann can no longer set a bit lnd defines, so they can't turn the add-on on behind its option's back.
  • A dependency on option_channel_type, stated in the spec.
  • The protocol.independent-secrets flag, with an entry in sample-lnd.conf. dev.independent-secrets-sender is refused without it.
  • The channel acceptor reports the commitment type without the bit, as for option_unified_sigs, and the bit itself as wants_independent_secrets = 2001 on ChannelAcceptRequest.
  • independent_secrets = 2001 on Channel, PendingChannelsResponse.PendingChannel and ChannelCloseSummary, filled for open, pending-open, waiting-close, force-closing and closed channels. 2001 keeps it apart from the fork's own fields, which are numbered on from 1001 (splicing took 1002 to 1006).
    • The change to lightning.pb.go is only what protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the new field; the rest of the file is as upstream has it.
    • The swagger entry was added to match.

Storage cost: each revoked state's log entry grows by 38 bytes: the 32-byte secret plus its TLV header. lnd already keeps a log entry for every revoked state. The spec lets a receiver keep a seed-based peer's secrets in the compact 49-entry form for as long as they fit it, as the CLN fork does. lnd doesn't need that: it writes a log entry per revoked state anyway, and its static channel backups never carried revocation state.

Database version (channeldb/db.go, channeldb/channel.go, channeldb/migration39)

  • An older version can't decode the revocation state of a channel with the add-on (an IndependentStore). It used to fail to decode the channel while it was open or being resolved, and once it had closed, start and fail closedchannels and pendingchannels.
  • Version 39 now marks a database that has stored such a channel, the way the fork's version 38 marks one that ran with splicing. syncNewChannel, which saves every new channel (including the fundee's pending one, saved before any funding transaction exists), moves the database to 39 in the same transaction when the channel type has the bit. An older version then refuses to start with lnd's usual "Refusing to revert from db_version=39".
  • Like 38 it changes no data, and it is never run as a migration: targetVersions leaves out 39, and 38 too with splicing off, picking them by number (commit 19), so a migration appended after them still runs. Only markIndependentSecrets reaches 39. A node that never has such a channel stays at 37 or 38 and can still go back to an older release. A database at 39 opens with splicing on or off, OpenableDBVersions lists it for copying tools (lndinit's SQLite conversion), and Wipe still initialises at 38.
  • If upstream lnd later adds a migration numbered 39, the fork will need to renumber one of them, as for its own 36–38.

Prior work

This implements the "remove shachain" option first proposed by ZmnSCPxj on Delving Bitcoin as no_more_shachains, which is Option 2A in his September summary of the options ([thread](https://delvingbitcoin.org/t/towards-a-k-of-n-lightning-network-node/2395)). The difference is in how it's negotiated:

  • ZmnSCPxj's version: two node-level bits.
    • The odd bit means "I store all your secrets but still send shachain ones" (gateways).
    • The even bit means "I send non-shachain secrets" (k-of-n nodes).
  • This version: one standard bit pair, applied per channel through channel_type, as option_unified_sigs, taproot and zero-conf are.
    • A gateway signals 267.
    • A k-of-n node refuses any channel whose channel_type lacks the add-on.
    • Existing channels are unaffected.

Other approaches: Option 2B (several shachains, at most 5 signers) and Iceberg/shachain-mpc (shachain computed in MPC, no protocol change, not production-ready). The Iceberg authors write that Lightning may "drop the SHA-chains ... in the future". I found no existing BOLT PR or implementation of this change.

Review

The series had three independent reviews: one of the first version, one of the core series, and one of the additions. None found a critical or high-severity issue. Every finding was fixed except the three listed under "Left as is" below:

  • The dependency on option_channel_type wasn't in the spec. It is now.
  • Hardening:
    • The secret written to the log must be the newest one in the store.
    • channel_reestablish falls back to the log for the secret it needs.
    • The channel type bit moved to 1 << 40.
    • The dev sender's local bit is refused without the add-on.
    • The dev sender derives taproot nonces from the new producer.
  • Downgrading: the wording is now exact. lnd keeps a copy of a closed channel's state, so an older version can't read it even after the channel closes, and downgrading is not supported once any channel has used the add-on.
  • Test gaps filled:
    • the unproposed-echo refusal;
    • the acceptor mapping;
    • the store-type fallback;
    • noRevLogAmtData with a secret;
    • the dev sender on taproot.

Left as is, with the reason:

  • req.IndependentSecrets is set after the struct literal rather than inside it. Moving it would realign every line of two literals, which would make rebasing onto upstream harder.
  • Callers that name a channel type get the add-on added when both peers support it, as with option_unified_sigs. There is no per-channel opt-out, only the global flag.
  • A bad secret leads to a disconnect rather than "error and fail the channel". That is upstream behaviour for every revocation error.

Tests

Commits 20 to 24 come from the maintainer's two reviews, and the branch is now on this PR's base, blake2b (0.21.4-beta-blake2b.21, eb0848bad, the same commit as blake2b-next), rebased without conflicts. On that base, with all of them:

  • go build ./... and the cmd/lnd and cmd/lncli integration and release builds pass, and the itest compiles (go test -c). go build -tags=integration ./... stops at itest/config.go, which uses a function from a test file; it does the same on the untouched base.
  • These packages' tests pass in full: shachain, channeldb/..., feature, chanacceptor, lncfg, funding, contractcourt, watchtower/..., lnrpc, peer, and lnwallet with its subpackages. The exceptions are lnwallet/test/{bitcoind,btcd,neutrino}, which need those backends installed.
  • New tests: TestAcceptorReportsIndependentSecrets, TestCustomFeaturesRefuseKnownBits and TestInitRefusesIndependentSecretsEvenBit (both fail without commit 22), a database-version case for a store that disagrees with the channel type, and a commitment point check in TestIndependentSecretsSpliceBreach.
  • The new TestDevIndependentSecretsSenderNeedsProtocolOption passes in the integration build.
  • A release build without the flag doesn't advertise bit 267; with protocol.independent-secrets it advertises it as optional.
  • All nine interop scenarios with the CLN fork pass, with lnd started with protocol.independent-secrets; the two cooperative-close scenarios also check that closedchannels reports the add-on.

The text below describes the earlier move to .20.

Commit 19 comes from a further review: targetVersions picked the marks by their position at the end of dbVersions, so the next migration appended there would have been taken for a mark and never run. It now picks them by number, and TestTargetVersions checks that a migration appended after the marks still runs. The same commit corrects the docs: a static backup doesn't carry the bit, so it's an open that marks the database, including the pending channel a fundee saves before any funding transaction exists. With the add-on on, a peer can therefore mark the database just by starting an open, which is why it now ships off by default. go build ./... and every channeldb/... test pass with it.

The branch was then moved to Lightning Fork 0.21.4-beta-blake2b.20 (cee9561d5). The rebase had one conflict: upstream's "refine revocation validation order" now checks the secret against the commitment point before storing it, after a new check that the remote chain has an unacked commitment, so commit 4 only adds its note and the valid-private-key check there. Commit 18 (the database version) is new, as the maintainer asked. On this base every commit builds and passes go vet for the packages it touches (which compiles their tests), and with the final code:

  • go build ./..., the cmd/lnd and cmd/lncli integration and release builds, and go vet of every touched package pass.
  • These packages' tests pass in full: shachain, channeldb/... (with the new TestIndependentSecretsDBVersion, which fails if the marking is removed), feature, chanacceptor, lncfg, funding, contractcourt, watchtower/wtclient and lnwallet.
  • In lnwire, chanbackup and htlcswitch, every test that fails also fails the same way on untouched 0.21.4-beta-blake2b.20: TestQueryShortChanIDsZlibCompatibility (Go 1.26.8), TestCreateArchiveFile (running as root), and TestChannelLinkBandwidthConsistency, TestChannelLinkTrimCircuitsNoCommit, TestPipelineSettle, TestChannelLinkCleanupSpuriousResponses and TestSwitchDustForwarding. Our own tests in those packages pass, including all six TestChannelRetransmission cases, with and without the add-on.
  • All nine interop scenarios with the CLN fork pass, with lnd built from this branch.

A database that already holds a channel with the add-on, saved by an earlier build of this branch, stays at 37 or 38 until another such channel is saved; the branch was never released, so no node should be in that state.

The text below describes the earlier move from .18 to .19.

This branch is on Lightning Fork 0.21.4-beta-blake2b.19 (9cbe4083b), which adds splicing and needs Go 1.26.8 to build. Moving it there from .18 meant three small conflicts (the point check in ReceiveRevocation now follows upstream's splice-restore check; a field each in server.go and lightning.proto, where our field moved to 2001) and commit 17 above, which an audit of the splice code found was needed. On .18, lnd 0.21.4 always proposes an explicit channel type and no longer falls back to the legacy type. That conflicted with the negotiation commit in two files, and resolving it removed code rather than adding any: the add-on now only goes into explicit types, the test case for an open without channel_type is gone, and the unproposed-echo test now checks the funder's general rule that the reply's type must equal the one it proposed. Every commit builds and compiles the tests of the packages it touches. Everything below was rerun on this base, with the bits at 266/267.

New:

  • channeldb, database version: a channel with the add-on moves the database to 39 (also one with the restored status), and a second leaves it there; a shachain channel leaves the version alone; a marked database opens with splicing on or off and runs no migration; a newer one is refused. The splicing version tests and TestOpenableDBVersions now expect 39 as the latest.
  • shachain
    • The store accepts unrelated secrets and serves the last two.
    • Encoding round trip for 0, 1, 2 and 7 entries, with trailing data after the store.
    • A legacy store still decodes.
    • IndependentProducer secrets are deterministic, distinct and valid keys. A shachain store refuses them. The producer encodes the same root as a shachain producer.
  • channeldb
    • Round trip of the revocation log TLV, with and without HTLC amount data.
    • Store selection by channel type.
    • UsesIndependentSecrets for every bit/store combination.
    • The newest-secret check.
  • channeldb/migration30
    • The optional revocation-log migration succeeds when a channel with an independent store is present.
    • It fails without the codec change.
  • lnwallet
    • Six rounds of random secrets from Bob, then a retribution for every revoked state, checking the commitment point. This is done before and after a restart, followed by a reestablish and further updates.
    • Control: on a channel without the bit, the shachain store rejects the same secrets.
    • A secret of the form s + n is rejected. The test fails without the range check.
    • ChanSyncMsg works mid-revocation, and from the log when the store has moved two secrets ahead.
    • Both sides use an IndependentProducer on anchor and taproot channels, across a restart of both.
  • contractcourt
    • The chain watcher's copy of the channel is loaded from the database before the channel advances, so its in-memory store is stale.
    • Bob broadcasts revoked state 2. The watcher detects it and builds the retribution from the log.
  • wtclient
    • On a channel with random secrets, the client builds the backup for a revoked state from the database.
    • The tower decrypts it and builds the justice transaction.
    • Every input validates against the breached commitment under the script engine.
    • The test fails if the stored secret is off by one bit.
  • chanbackup: a backup of a channel with the add-on equals one without it.
  • chanacceptor: the acceptor reports the commitment type with the add-on present, and sends wants_independent_secrets exactly when the proposed type carries the bit.
  • feature
    • Signalled in init and node_announcement only when turned on, and only as optional.
  • config: dev.independent-secrets-sender without protocol.independent-secrets is refused.
    • Depends on explicit channel types.
  • funding
    • Negotiation table.
    • Unproposed-echo refusal: a reply that adds the bit to the proposed type is refused.
    • Full funding flow between two funding managers in six cases:
      • both peers signal it;
      • one side off;
      • explicit taproot;
      • the dev sender;
      • the dev sender on taproot;
      • the dev sender without the add-on.
    • In each case the test checks the persisted bits, the store type and the producer type on both sides.
  • htlcswitch: every TestChannelRetransmission case also runs on channels with the add-on where both sides send KDF secrets.
  • lnwallet, splicing: upstream's splice breach and watchtower tests, run on a channel with the add-on where the peer reveals random secrets: every revoked commitment, on the old and the new funding, while pending, at the lock and after it, is punished, also after a restart, and a tower backup is built for each. A third test checks the fallback to the channel's own log. All three fail without commit 17.
    • Real links exchange real messages, are restarted from the database, reestablish and retransmit revoke_and_ack.
    • The test fails if the producer isn't rebuilt from the database.

Passing:

  • Full test suites:
    • the root package, shachain, channeldb (plus migrations 30, 01_to_11 and 21);
    • lnwallet, contractcourt, funding, feature;
    • chanacceptor, lncfg, peer, lnrpc, watchtower/....
  • htlcswitch with -tags dev.
  • lnwire, except TestQueryShortChanIDsZlibCompatibility, which fails the same way on the untouched release built with Go 1.26.8. It checks zlib output sizes and doesn't touch this code.
  • chanbackup, except TestCreateArchiveFile, which fails when run as root. That is environmental and unrelated.
  • Builds: go build ./..., the integration dev build, the itest binary, and a release build (the release tags, without the kvdb backends) used for the interop below.

Interop with the CLN fork ([PR #48](privkeyio/lightning#48)), on a Bitcoin Knots BLAKE2b regtest: nine scenarios, each on a fresh chain, all passing on this base (script interop.py and its CLN plugin hold.py, in [davelund110/independent-secrets-interop](https://github.com/davelund110/independent-secrets-interop) with a README on the setup; lnd runs with protocol.independent-secrets).

  • lnd sends KDF secrets (this PR's integration build with --dev.independent-secrets-sender; that build never signs with the unified sighash, so CLN ran with --dev-force-features=-515, and the channels were [12, 22, 266]):
    • lnd force-closes with revoked state 12 of 28, restored from a copy of its database, and CLN takes its whole balance with the secret it stored;
    • the same with an HTLC in flight and CLN restarted mid-penalty;
    • 100 rounds of payments with restarts, then state 200 of 400;
    • a cooperative close.
  • CLN sends KDF secrets (a release build of this PR: [12, 22, 266, 514], so option_unified_sigs too):
    • after both restart and check your_last_per_commitment_secret, CLN broadcasts revoked state 13; the breach arbiter builds the justice transaction from the revocation log, and lnd ends with 990,347 of the 1,000,000 sat;
    • the same with 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;
    • 400 updates with restarts, then a state from the middle: lnd takes everything;
    • a cooperative close.
  • A shachain peer: a release lnd sends ordinary secrets, and CLN keeps them all in its shachain and punishes from there.

Not run:

  • The itest. The fork's integration harness can't start lnd yet:
    • The harness's lnd nodes use btcd, which the fork refuses.
    • A Bitcoin Knots bitcoind with BLAKE2b and --bitcoin.blake2b-activation-height aren't wired into lntest.
    • The test is written and compiles, and will run once the harness supports the BLAKE2b chain. The interop script above covers the same ground against the CLN fork.
  • A real threshold signer.

Rollout

  1. First release: off by default, turned on with protocol.independent-secrets. That release reads the new store and database version 39. In the CLN fork ([PR #48](Add option_independent_secrets channel type privkeyio/lightning#48)) it is off by default too, turned on with --independent-secrets. A new channel uses it when both sides offer it. With older releases, channels open the normal way, so nothing breaks.
  2. The release after: on by default. A rollback from it lands on a release that already reads version 39.
  3. 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) store every per-commitment secret the peer reveals, instead of requiring them to come from a shachain. This is what lets a peer run its node with k-of-n signers without any one signer being able to steal the channel. It's off by default; protocol.independent-secrets=true turns it on. listchannels shows it as independent_secrets. Downgrading to an older release is not supported once any channel has used it: the database moves to version 39 with the first such channel, so an older release refuses to start. See docs/independent-secrets.md.

Not in this PR

@davelund110

Copy link
Copy Markdown
Author

A review raised one point that’s your call, Paul, rather than a fix. With the bit on by default, any peer can move the database to version 39 just by starting an open, even one it never completes, because the fundee saves the pending channel before any funding transaction exists. After that the node can’t downgrade. The mark is still needed then, since that pending record can’t be read by an older version; the question is the default. Options: (1) keep it on and say so in the release notes (the docs now do); (2) ship it off by default at first, like the CLN fork; (3) add a guarded downgrade tool. Happy to do whichever you prefer. Commit 19 has the other review fixes.

IndependentStore is a Store for per-commitment secrets that have no
shachain relationship, as on a channel with option_independent_secrets.
It accepts any secret, keeps the two most recent in memory (enough for
channel_reestablish, including mid-revocation), and starts its encoding
with a 0xff marker, which a RevocationStore encoding never does.
NewStoreFromBytes decodes either kind.
The channel codec and the migration codecs now decode a revocation
store with shachain.NewStoreFromBytes, so they read an IndependentStore
as well as a RevocationStore. Legacy stores decode exactly as before.
The optional revocation log migration (migration30) reads the
revocation state of every channel, so without this it would fail once
a channel with an IndependentStore exists.
Add IndependentSecretsBit, NewRemoteSecretStore, which picks the store
for a channel type, and OpenChannel.UsesIndependentSecrets, which also
trusts the store type so the two can't disagree silently. The bit is
1 << 40, far above upstream lnd's allocations, so a rebase onto
upstream can't hand it another meaning.

On such channels the secret that revokes a remote commitment is written
to that commitment's revocation log entry (optional TLV 65537) in the
same transaction that moves the commitment into the log. It must be the
newest secret in the store; a store out of step with the commitment
chain is refused rather than answered with the secret before it.

ChanSyncMsg falls back to the revocation log when the in-memory store
no longer holds the secret it needs, which a copy of the channel
refreshed in two steps can run into.
ReceiveRevocation checks that a secret generates the expected point
before storing it, since on these channels that is the only check a
secret gets, and also requires it to be a valid private key as sent.
NewBreachRetribution reads the secret from the revocation log on these
channels and from the shachain store otherwise. Reservations take an
IndependentSecrets flag that sets the channel type bit and the store.
The chain watcher's copy of the channel is loaded before the channel
advances, as the chain arbitrator's is, so its in-memory store is stale
and the retribution must come from the revocation log.
Bits 266/267, proposed in a draft bLIP. The add-on is only
ever agreed through channel_type, so it depends on explicit channel
type negotiation.
Mirrors option_unified_sigs: proposed whenever both peers signal it,
stripped before the commitment type is matched, kept on the persisted
type, refused explicitly by a peer without it, refused if a peer echoes
it unproposed, and allowed on taproot channels. The RPC acceptor strips
the required bit like the other add-ons.
Lightning on BLAKE2b Bitcoin is new, so the optional bit is signalled
by default, like the fork's other XBT features, and every new channel
with a peer that also signals it uses the add-on. Channels with peers
that don't signal it open as before. protocol.no-independent-secrets
turns it off.

Downgrading to a version without it is not supported once any channel
has used it: such a version can't read that channel's stored state,
which lnd also keeps after the channel closes. The config help says so.
A static channel backup only lets the node ask its peer to force close,
after which it never receives a revocation again, so the add-on has
nothing to restore. Pin that a channel with it backs up exactly like the
same channel without it.
Runs the watchtower path end to end on a channel whose peer reveals
random secrets: the client builds the backup for a revoked state from
the database, as lnd's server does, the tower decrypts it and builds the
justice transaction, and every input must validate against the breached
commitment. The test fails if the stored secret is off by a single bit.
Add independent_secrets to Channel and PendingChannelsResponse.
PendingChannel, and fill it for open, pending open, waiting close and
force closing channels, so operators can see which channels store every
secret.

The field is 2001, away from the fork's own fields, which are numbered
on from 1001 (splicing took 1002 to 1006). The change to lightning.pb.go
is only what protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the
new field; the rest of the file is left as it was.
An IndependentProducer derives the secret for index i as an HMAC of i
keyed by the root, as option_independent_secrets allows. Like a shachain
it needs nothing but its root, which it encodes exactly as a
RevocationProducer does, but its secrets can't be compressed, so only a
peer that stores every secret accepts them.
With --dev.independent-secrets-sender, available in integration builds
only, a node generates its own per-commitment secrets with an
IndependentProducer on every new channel with option_independent_secrets,
so that a peer's support for them can be exercised by a real node.

The channel records this in LocalIndependentSecretsBit, a local channel
type bit, and the producer is rebuilt from the stored root when the
channel is loaded; a type with the bit but without the add-on fails to
load. The root is stored as before, and the taproot nonce producer is
derived from the new producer exactly as after a restart, so nonces
stay consistent.
Every TestChannelRetransmission case now also runs on channels with
option_independent_secrets on which both sides send secrets without a
shachain: links exchange real messages, are restarted from the
database, reestablish and retransmit revoke_and_ack.
Two nodes, one sending independent secrets with the dev option, open a
channel with option_independent_secrets by default, pay across restarts,
and the sender then broadcasts a revoked state, which the other node
sweeps. The fork's harness can't start lnd yet (it needs a BLAKE2b
Bitcoin Knots backend), so this runs once it can.
What the add-on does, why, how to turn it off, the downgrade caveat,
the RPC field and the dev option.
While a splice is pending, the peer has a commitment on each funding
output at the same number, and its revoked ones on a splice's funding go
into that splice's own revocation log. A breach on that funding, or a
watchtower backup of it, is built from the splice's log entry. Those
entries were written without the per-commitment secret, so on a channel
with option_independent_secrets the commitment couldn't be punished.

One secret revokes the commitment with that number on every funding
output. AdvanceCommitChainTail now passes it to applySpliceWrites, which
stores it with each splice's entry at that number. As a fallback for an
entry without it, revokedCommitSecret takes the secret from the
channel's own log at the same number, which is written in the same
transaction.

The new tests run the splice breach and watchtower tests on a channel
with the add-on, where the peer reveals random secrets. They fail
without this change.
…annel

An older version can't read the revocation state of a channel with
option_independent_secrets. It used to fail to decode the channel while it
was open or being resolved, and once it had closed, start and fail its
closedchannels and pendingchannels calls.

Database version 39 now marks a database that has stored such a channel:
syncNewChannel moves the database there, in the same transaction, when it
saves a channel with the add-on, new or restored from a backup. So an older
version refuses to start with the usual "Refusing to revert" error. Like the
splicing mark (38), it changes no data. It is never run as a migration, so
a node that never has such a channel stays at 37 or 38 and can still go back
to an older release. A database at 39 opens with splicing on or off, and
OpenableDBVersions lists it for copying tools.
targetVersions left out the independent secrets mark (39) and, with
splicing off, the splicing mark (38) by their position at the end of
dbVersions. The next migration appended there would have been taken for
a mark and never run. Leave them out by number instead, and test that a
migration appended after them still runs.

Also correct two comments and the docs: a static backup doesn't carry
the add-on's bit, so it's a channel being opened that marks the
database, including the pending channel a fundee saves before any
funding transaction exists. With the option on by default, a peer can
therefore mark the database by starting an open; the docs now say so,
and how to keep the option to downgrade.
Once a channel with the add-on is stored, the database moves to version 39,
which an older release refuses to open. With the add-on on by default, two
upgraded nodes would use it on every channel they open, so nearly every node
would be marked right after upgrading and couldn't roll back if something
else in the release went wrong.

So it now ships off, and protocol.independent-secrets turns it on, replacing
protocol.no-independent-secrets. A release that can read version 39 can then
go out first; turning it on by default in a later release leaves a rollback
landing on a release that already reads it.

dev.independent-secrets-sender now needs protocol.independent-secrets, since
its secrets are only accepted on channels with the add-on. The itest turns
the option on for both nodes.

The feature bit comment and docs now point to lightning-blake2b/bolts#17
instead of a draft bLIP.
The channel acceptor's commitment_type sets the add-on's bit aside, since it
doesn't change the commitment's shape, so an acceptor couldn't tell whether
accepting a channel meant storing every secret the initiator reveals.

ChannelAcceptRequest gains wants_independent_secrets (field 2001), set when
the proposed channel type carries the bit. The lightning.pb.go change is only
what protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the new field.
@davelund110
davelund110 force-pushed the independent-secrets-blip branch from 9c90126 to 679e8c0 Compare October 21, 2026 02:07
@paulscode

Copy link
Copy Markdown
Owner

Thanks Dave. As I said on Discord, I've read through this, and it builds and passes here, on its base and rebased onto blake2b-next. The care that went into the breach paths shows.

On your question about the default: I'd like to ship it off by default first. Two upgraded nodes would use it on every channel, so nearly every node would move to database version 39 and lose the option to roll back, while no node sends secrets that need it yet. So for the first release: keep the code that reads version 39 and the new store, and turn the add-on on with protocol.independent-secrets instead of off with protocol.no-independent-secrets. The release after that turns it on by default, and a rollback from that one lands on a release that reads 39, so no downgrade tool is needed. I want this to get used, not sit behind a flag: the off-by-default step is only there to keep rollbacks safe.

A few other things before merging:

  • Could you share interop.py and hold.py, and the regtest setup they
    expect (Knots version, how the nodes start)? I'd like to run the nine
    scenarios in our lab, plus a breach on a spliced channel.
  • lnwire/features.go and docs/independent-secrets.md still call the spec
    a draft bLIP; it's BOLT 2, 3, 9: option_independent_secrets lightning-blake2b/bolts#17 now.
  • The RPC channel acceptor drops the bit, so an operator can't see it or
    refuse it per channel. Fine for now, but worth a line in the docs.
  • I've left some comments on the spec in BOLT 2, 3, 9: option_independent_secrets lightning-blake2b/bolts#17. The channel_reestablish one
    doesn't affect this PR, since LND still sends shachain secrets, but I'd like
    the spec settled before we merge.

I'll approve the CI runs now.

@paulscode

Copy link
Copy Markdown
Owner

I wrote that before seeing your two new commits: off by default with protocol.independent-secrets, the lightning-blake2b/bolts#17 wording, and wants_independent_secrets for the acceptor. That covers the default, the docs and the acceptor points above, thanks. What's left from my list is the interop scripts and the spec discussion. I've approved CI for the new head.

@paulscode

Copy link
Copy Markdown
Owner

A second, closer pass over 679e8c0 found nothing that affects funds. A few small things:

  1. protocol.custom-init / custom-nodeann can set 267, which turns the add-on on with protocol.independent-secrets off (so the database can move to 39), or 266, which the spec forbids in init. Refusing custom bits that are known features (feature/manager.go ~262) would close it; other known bits have the same gap today.
  2. A peer that sets 266 in init is treated as supporting the add-on, since HasFeature(267) is true when 266 is set, and a node with the option off no longer disconnects a peer requiring 266. Either disconnect such a peer or test IsSet(267) only.
  3. TestIndependentSecretsSpliceBreach checks balances; comparing br.KeyRing.CommitPoint with the point of the revealed secret would also catch a wrong key.
  4. Hardening, not reachable today: mark the database when UsesIndependentSecrets() is true rather than on the channel-type bit alone, and have revokedCommitSecret say plainly when the secret is missing.
  5. Docs: a static-backup restore rebuilds a shachain producer, so a dev-sender node restored from a backup can't sweep its own to_local with an external tool; worth a line.
  6. Nits: the syncNewChannel comment says a restored shell can't carry the bit, but the restore test marks the database; the IndependentSecretsRequired comment should say it's only valid in channel_type; in the itest, output 2 is Bob's revoked to_local, the second justice input is Carol's to_remote, and --hodl.exit-settle on Carol has no effect; one unwrapped paragraph in docs/independent-secrets.md; ClosedChannels could carry independent_secrets too, since a closed add-on channel still blocks a downgrade.

CI is green on this head.

protocol.custom-init and protocol.custom-nodeann could set either bit of
option_independent_secrets: 267 turned the add-on on with
protocol.independent-secrets off, so the database could move to version 39,
and 266 is not valid in init at all. The feature manager now refuses custom
bits that lnd defines, as updates at runtime already do.

A peer that set 266 in init counted as supporting the add-on, since
HasFeature(267) is true when 266 is set, and a node with the option off no
longer disconnected it, since lnd now knows the bit. Such a peer is now
refused in init, and negotiation checks bit 267 itself.
From the maintainer's second pass:

- syncNewChannel marks the database when UsesIndependentSecrets() is true,
  not on the channel-type bit alone, so a channel whose store and type
  disagree still marks it. Not reachable today; a test covers it.
- revokedCommitSecret says plainly that a revoked state has no secret and
  can't be punished, and passes on the error when the channel's own log
  entry can't be read.
- TestIndependentSecretsSpliceBreach also checks that each retribution's
  commitment point is the point of the secret Bob revealed, which the
  balances alone wouldn't catch.
- closedchannels reports independent_secrets (field 2001 on
  ChannelCloseSummary), since a closed channel with the add-on still keeps
  the database at version 39. The lightning.pb.go change is only what
  protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the new field.
- The syncNewChannel comment no longer says a restored channel can't carry
  the bit.
- docs: the channel acceptor reports wants_independent_secrets, and
  closedchannels reports the add-on; a dev-sender node restored from a
  static backup gets a shachain producer back, so it can't sweep its own
  to_local with an external tool; rewrap a paragraph.
- lnwire: IndependentSecretsRequired is only valid in channel_type.
- itest: output 2 of the breach is Bob's revoked to_local, and the justice
  transaction's other input is Carol's to_remote; drop --hodl.exit-settle,
  which had no effect on Carol.
@paulscode

Copy link
Copy Markdown
Owner

Thanks for the quick fixes; 9b0d0b3 covers everything from my second pass, and CI is green.

Since the interop script wasn't available yet, I ran our own lab scenarios against privkeyio/lightning#48 (4df42d549) on a Knots BLAKE2b regtest, with lnd at 9b0d0b3 (a release build, and an integration build for the dev sender). All of these passed:

  1. Negotiation: lnd on with CLN on gets the add-on, opened from either side, and lnd's database moves to 39. lnd on with CLN off, and lnd off with CLN on, open normal channels. CLN naming the add-on to lnd with the option off is refused, and the database stays at 37. 0.21.4-beta-blake2b.21 with this branch (option on) opens normal channels both ways.
  2. Normal use with both sides sending non-shachain secrets: payments both ways, a held HTLC across restarts of both nodes, reconnects, a cooperative close and force closes from each side, with every output resolved.
  3. CLN broadcasting a revoked state: lnd sweeps it, with and without an HTLC in the revoked state.
  4. lnd broadcasting a revoked state: CLN sweeps it, with and without an HTLC. With the HTLC, CLN's penalty for it pays below the minimum relay fee at the fee floor, which lightningd: count the witness stack element when sizing onchain spends privkeyio/lightning#49 fixes; the lab raised its fee by hand.
  5. Splices: revoked states from before a splice-in (on the old funding output, mined while the splice was pending), signed while it was pending, and after it locked (on the new output) are all punished by lnd, including after an lnd restart with the splice pending. CLN punishes lnd's pre-splice and post-splice breaches.
  6. Watchtower: with the victim offline, an lnd tower punishes the breach, with either side as the non-shachain sender.
  7. A peer setting 266 in init is refused by lnd with the option on or off.

One thing for anyone repeating this with the dev sender: an integration build never opts into the unified sighash (input/sighash_dev.go), so it doesn't advertise 514 and the CLN port opens no channel with it. I patched DefaultUnifiedSigHash() to return true for the lab build. It only affects test builds, so no change needed here unless you want the integration build to follow the release default.

I'd still like to see interop.py and hold.py when you have a moment, to compare coverage.

@davelund110

Copy link
Copy Markdown
Author

Thanks, great to hear it all passed in your lab, splices and towers included.

The scripts are here, with a README on the Knots version, the lnd and CLN builds and how the nodes start: https://github.com/davelund110/independent-secrets-interop

On the unified sighash: we hit the same thing and handled it on the CLN side instead, running CLN with --dev-force-features=-515 in the scenarios where lnd's integration build sends (those channels are [12, 22, 266]); the others use a release lnd, so they carry 514 too. Happy to leave the integration build as it is.

I'll go through your spec comments on bolts#17 next.

@paulscode

Copy link
Copy Markdown
Owner

Thanks for publishing the scripts. All nine interop.py scenarios pass here too, with this branch at 9b0d0b3 (integration and release builds) and privkeyio/lightning#48 at 8aebea0ec on Knots v29.4.1.knots20260508. Our own lab scenarios also pass again against 8aebea0ec, including CLN now refusing a peer that sets 266 in init. The two sets complement each other well: yours covers the long runs, restarts with penalties unconfirmed and the shachain peer, ours the negotiation matrix, splices and towers.

@paulscode
paulscode merged commit b2375a7 into paulscode:blake2b Oct 22, 2026
2 checks passed
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.

2 participants