Skip to content

Add option_independent_secrets channel type - #48

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

davelund110 wants to merge 13 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 (PR #7); 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). 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,040 added lines of C outside the tests; with tests, docs and regenerated bindings, 59 files, +2,350/−101. 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.

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.
  • 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.
  • 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 we started a mutual close at 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 one of our own commitments can unmask to a revoked number too. onchaind recognises those first and never uses the secret.
  • 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, CHANNELD_AWAITING_SPLICE 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.
  • 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, which needs no HTLCs, 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 (one with independent_secrets_close_at set), with whatever is left of its time since the channel moved to CHANNELD_SHUTTING_DOWN, as its state changes record. A close the user or the peer started is left alone.
  • The 10% counts from how many the channel stored when we started the close, saved in the database, so a cap lowered below what a channel already stores starts a mutual close at its next update rather than failing the channel at the one after, and a restart doesn't give the peer another 10%. Without such a close (a lease, or a close that couldn't start) it counts from the cap itself.
  • 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, including ones without the option, so it is only for testing against peers that support the option. On a channel without it, the peer refuses our second secret (the first always fits an empty shachain), and the node breaks the spec's rule that a node whose secrets don't come from the seed must not open such a channel. That's acceptable for a developer-only switch; making hsmd aware of each channel's type would cost more than it's worth.
  • 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, thread), 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

An independent 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 independent 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). An independent 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).

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: 24 passed with v1 opens, and 14 passed with dual-funded opens (the v1-only tests skipped), including the five new cap tests. So does test_splice_stored_feerate_repaired_on_upgrade, which rewinds the database version and now drops the new column too.

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

  • 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 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.
  • 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 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 and 7974591 with the bits at 266/267 (script interop.py and its plugin hold.py, which I can share).

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 (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, but the 10% backstop applies before then. 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; on by default in Lightning Fork. 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 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 (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 schema and the config docs say "added in v26.06.9-blake2b.8", the release after the current v26.06.9-blake2b.7. Change that if the release is named differently.

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).

@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.

… 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.
@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.

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.
@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.

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.

2 participants