Skip to content

Lightning identity for the BLAKE2b chain: the invoice prefix and the gossip floor - #1

Open
paulscode wants to merge 7 commits into
privkeyio:blake2b-unifiedfrom
paulscode:blake2b-chain-identity
Open

paulscode wants to merge 7 commits into
privkeyio:blake2b-unifiedfrom
paulscode:blake2b-chain-identity

Conversation

@paulscode

@paulscode paulscode commented Sep 14, 2026

Copy link
Copy Markdown

This replaces the fifteen-commit series that was here. That one gave the
BLAKE2b chain a chain_hash of its own; it is withdrawn, because a change of
proof of work is not a change of chain, and chain_hash stays the genesis hash
both chains share. Seven commits now, and the two that carry behaviour are the
invoice prefix and the gossip floor.

Summary

v26.06.7-blake2b.4 follows the chain across the activation and signs with the
unified signature hash. Two things are still missing before a node on this
chain can be told from a node on the chain that did not upgrade, and one of
them currently stops our two implementations paying each other at all.

The invoice prefix. chain_hash is shared, so a BOLT 11 invoice carries
nothing that says which chain it is for. The prefix is the only place that can.
This series gives the chain lnblake, lntblake, lntbsblake and
lnblakert, and keeps the old prefix per network so that decode still
recognises an lnbc string and refuses it with a reason rather than failing to
classify it.

The direction that matters most is outward. A node on the other chain predates
this chain and will never be updated, so with a shared prefix an invoice minted
here is decoded there, found well formed, and paid on the other chain. That is
an argument from compatibility with deployed software, not from which chain is
which.

The gossip floor. A funding output from below block 961,640 exists for
nodes that did not upgrade too, and its spend may happen where this node cannot
see it, so a channel announced against one would sit in the graph forever. The
chain_hash check cannot do this, because both chains carry the same value.
This is the rule in the BOLT 7 half of lightning-blake2b/bolts#1, placed
where that text places it.

What this series does not do

  • It does not touch chain_hash. That was the previous series' mistake.
  • It does not add feature bits. This build already sets option_blake2b
    and option_unified_sigs.
  • It does not fix BOLT 12. An offer names chains by chain_hash, so an
    offer minted on either chain reads as valid and for the reader's own chain,
    on both implementations, with no warning. That gap is real and is
    deliberately left open: minting offers that name a chain the other
    implementation does not recognise would break fetching between us, and that
    is not a thing to do unilaterally. doc/blake2b-chain-identity.md, which
    commit 5 adds, sets the gap out in full under "Offers: a known gap", and I
    have raised it in a comment here as the one thing I would most like a view
    on.
  • It does not restamp wallets. The previous series did. That check is
    reached only when the wallet is stamped with block 0 and the chain's
    chain_hash is not, so with the two equal it cannot fire. Measured: a wallet
    synced on one chain and restarted against the other rescans it to the tip and
    says nothing.

Measured

In a regtest lab, against Lightning Fork (github.com/paulscode/lightning-fork,
an LND port) which implements the same values:

  • Without this series: the two peer, agree channel_type [12,22,70],
    exchange gossip, and close both cooperatively and by force with 0x21 in
    both witnesses. They cannot pay each other. Each refuses the other's BOLT 11
    invoice on the prefix, before a route is considered:

    cln -> lnd:  Prefix blakert is not for regtest
    lnd -> cln:  invoice is for the SHA256 chain (prefix "lnbcrt500u"), not
                 the BLAKE2b chain this node follows (expected prefix
                 lnblakert)
    
  • With it: the same run pays in both directions, and both closes still
    carry 0x21.

That is the whole of what this series is for.

Commits

  1. bitcoin: give the BLAKE2b chain its own invoice prefix
  2. bolt11: say which chain a foreign prefix belongs to, and keep the reason
  3. gossipd: ignore channel announcements from before the proof of work changed
  4. tests: pin the invoice prefixes and the activation height
  5. doc: the chain identity, rewritten for the design that replaced chain_hash
  6. tests: skip the five that carry foreign-chain BOLT 11 fixtures
  7. tests: the invoice prefix, in the fixtures and the assertions

Unit tests

make check-units fails on exactly one target, fuzz-open_channel, and it
fails the same way on 24d027310 with nothing applied, so it is not this
series. Every other target passes, including the three this series touches.

Running them is what found a bug worth recording. The commit that says which
chain a foreign prefix belongs to read chainparams->legacy_lightning_hrp
while decoding, and decoding does not require a configured network: the daemon
always has one, but fuzz-bolt11 does not, so that was a null read and the
target segfaulted. Upstream never dereferences chainparams there. It is
guarded now, and the target passes.

That bug was in the previous series too, unnoticed, because the unit tests were
never run against it.

The python suite

Run against the files the prefix reaches, on this series and on 24d027310
with nothing applied, and the two failure sets now match. That suite is failing
a great deal on its own, before any of this: 85 of the tests in those files
fail on plain upstream. What matters is that the series adds nothing to that,
and it does not.

Getting there took two fixes, both found by the run and neither visible without
it. Both were in the previous series too.

The bookkeeper could not read its own history. It decodes the BOLT 11
strings it stored, with no chain check at all, and those were written with the
old prefix. chainparams_by_lightning_hrp only knew current prefixes, so the
decode failed and the migration aborted: failed to parse bolt11 lnbcrt1...: Prefix bcrt is the SHA256d chain's. That is accounting data lost on upgrade
for every payment made before it. The lookup now falls back to the prefix a
network used to carry, in a second pass so that a prefix still in use always
wins: testnet3 uses tb today and testnet4 used to, and an invoice saying tb
is testnet3's.

This does not make an old invoice payable. A caller that cares which chain an
invoice is for passes must_be_chain, and that path compares against
lightning_hrp directly rather than coming through the lookup. Checked:
lnbcrt is still refused on the pay path, and now decodes on the read path.

Two bookkeeper tests asserted the prefix as a literal. 'currency': 'bcrt'
in eighteen places across test_migration and test_migration_no_bkpr, which
the earlier series' test commit missed because it only changed the sites that
went through chainparams. They read the fixture now.

One test, test_wallet.py::test_reserveinputs, fails under -n 4 and passes
alone on both this series and plain upstream. Flaky, not a regression.

Since then the rest of the suite has been run the same way, every file on both
sides: test_connection, test_plugin, test_pay, test_closing,
test_gossip, test_opening, test_askrene, the splice family, the
unified_* files and test_blake2b_differentiation among them. The failure
sets match in every chunk. Nothing outstanding.

paulscode added a commit to paulscode/lightning-fork that referenced this pull request Sep 14, 2026
Rebased onto the v26.06.7-blake2b branch head; the restamp also removes
the gossip store, without which the re-announced channel never reached
a fresh node in the lab; the upgrade path is tested and its transcript
recorded with the interop transcript from the same build.

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

Verified the mainnet constant: it decodes to 0000000000000050c1e5f69672f459293be14f46e5a494e7a8c8541396f18eeb, which is block 961,640 on a live node. Two findings below.

Comment thread wallet/wallet.c Outdated
chainparams_block0(chainparams))) {
/* A one-way door, like a database upgrade in a
* non-final version: the operator says so once. */
if (!w->ld->db_upgrade_ok || !*w->ld->db_upgrade_ok) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

StartOS passes --database-upgrade=true unconditionally (cln-startos startos/main.ts:50), so this guard never fires there. Pointing that build at a Bitcoin wallet restamps it silently and one way, which is the case the flag is meant to prevent. Worth its own flag, or a check that the wallet has post-activation history.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You are right, and I checked: cln-startos startos/main.ts:50 is

const lightningdArgs: string[] = ['--database-upgrade=true']

with no condition on it, so the guard never fires there and the case it exists
for is exactly the case that gets through.

Worth being precise about the damage, because it changes what the fix has to
protect. The restamp does not touch keys or UTXOs. It rewrites the chain
identity stamp and clears announcement signatures for existing channels. A
Bitcoin wallet that goes through it has not lost coins, but it has stopped
being usable on the other chain without restoring from seed, and its channels
are announced under the wrong identity. One way, and bad enough.

I took your second suggestion, and then found it does not stand on its own, so
the fix is in two parts and the second part is a flag after all. My first
instinct was that a flag has the same failure mode as --database-upgrade,
being only as good as the most careless thing that sets it. What changed my
mind is that the failure mode is not "a flag" but "a flag that something else
already sets for its own reasons". A flag whose only purpose is this decision
has nothing setting it by habit.

It is a separate commit rather than an amend, so the change stands on its own
rather than being folded back into the one you reviewed: wallet: gate the restamp on the wallet's own history, not on a flag, and then wallet: a dedicated flag for the case the wallet cannot answer. The rebase means the
"changes since your review" view will not show them usefully, hence naming
them. On this chain's mainnet the chain_hash was taken from the
activation block, so a wallet that followed the fork has that block at that
height and a wallet from the SHA256d chain has a different one. Nothing has to
be asked of a backend, which matters because this runs at sanity-check time.
If the wallet has our block there, restamp regardless of the flag. If it has a
different one, refuse regardless of the flag. Only where the wallet has no
block at that height does the flag still decide. A new
chain_hash_block_height records where the value came from and is zero on the
networks that derive it by tagged hash, so the check stands aside there
instead of refusing every regtest wallet.

Here is why it needed a second part. I tried to make the refusal fire in the
lab by giving regtest a chain_hash_block_height, and it did not, because the
migration scenario creates fresh wallets which have no record of that height at
all. The same is true on mainnet of any wallet created after activation: it
never saw block 961,640, so the check stands aside. A wallet created after the
fork on the SHA256d chain and one created after the fork here are
indistinguishable from their block tables alone.

That is the population the wallet's own history cannot speak for, and for it
the decision has to come from the operator. So it comes from
--restamp-wallet-for-this-chain and not from --database-upgrade. The case
you found is that cln-startos passes --database-upgrade=true
unconditionally, so on that distribution the guard is permanently open; nothing
passes the new flag by habit, so on that distribution the guard is now
permanently closed unless somebody types it. It is a one-way door and the log
message says so.

What that leaves is narrower and I think acceptable: an operator who types the
flag at a wallet that genuinely followed the other chain, and whose wallet has
no block at the activation height to contradict them. Closing that needs the
chain rather than the wallet, comparing recorded blocks against what the
backend reports at the same heights. I looked at where that could go and it is
not this commit: wallet_sanity_check runs at lightningd.c:1332 and
setup_topology, which is the first thing that talks to bitcoind, is
fourteen lines later. So there is no backend to ask yet, and the honest options
are to move the restamp later in startup or to leave this to the operator. I
left it to the operator. If you would rather it moved, say so and I will do it
as a separate change.

Comment thread connectd/peer_exchange_initmsg.c Outdated
* the other one, and LND never named any. See
* doc/blake2b-chain-identity.md. */
status_peer_debug(&peer->id,
"Peer names no networks and this chain "

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

cln-application sends no networks TLV (LN_MESSAGE_CONFIG sets no chainHashes), so this drops every StartOS and Umbrel dashboard. lnmessage supports the TLV, but setting it pushes chain awareness into a UI that otherwise needs none. Same breakage a required feature bit caused, worth solving once for both.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed, and this one is a mistake rather than a tradeoff I made knowingly. I
was thinking about lnd nodes on the SHA256d chain, which have never sent the
field, and not about client applications that speak the wire protocol only to
reach a node's RPC and have no reason to know what chain they are on. Dropping
every dashboard is not a defensible default.

I did not want to fix it by making cln-application send the TLV. You are
right that it pushes chain awareness into a UI that otherwise needs none, and
it only fixes the clients we happen to know about.

The better argument is against my own patch. The init drop was never the
isolation, only defence in depth. A peer from the other chain that connects
anyway still cannot open a channel, because open_channel carries
chain_hash, and still cannot get its gossip accepted, because
channel_announcement carries it too. So it is now opt-in, renamed to
--drop-peers-without-networks because Core Lightning names an option for the
action it takes and "allow" defaulting to allowing would be a flag that does
nothing. A peer that names other chains but not ours is still dropped, as
before; that case is unambiguous.

Testing it produced a better demonstration than I expected. The interop
scenario used to assert that a stock Bitcoin lnd was dropped at init, and
that assertion could never have tested this: lnd drops Core Lightning itself,
because Core Lightning names a chain lnd does not serve, so "lnd is not a peer"
was true whatever Core Lightning did. With the drop off, what actually happens
is that lnd connects, sends a gossip_timestamp_filter carrying Bitcoin's
chain_hash, and gets "bad chain" back. The isolation holds one layer further
in, exactly where it should. The scenario asserts that now instead.

Your last sentence is the one I keep coming back to. This is the same shape as
the compulsory feature bit: something at init that refuses peers, where the
refusal catches clients it was never aimed at. I have written up the feature bit
side on the forum. If the principle is "do not refuse at init for things
chain_hash already covers", both of these fall out of it.

@paulscode
paulscode force-pushed the blake2b-chain-identity branch from dc1d611 to f2c35e8 Compare September 16, 2026 01:23
@paulscode
paulscode changed the base branch from v26.06.7-blake2b to blake2b-unified September 16, 2026 01:24
@paulscode

Copy link
Copy Markdown
Author

Thank you for decoding the constant against a live node rather than taking it
from the diff. That is the one value in this series where a typo would be
silent and expensive, and it is worth more checked by someone else than checked
twice by me.

For the record of how it got there: it is checkpointed in Bitcoin Knots since
v29.4.1rc5, and I read it off the chain and against an independent explorer on
2026-09-08 and again on 2026-09-12. Your read makes three, on a node I have
nothing to do with.

Two structural things, so the rest of the thread makes sense. The PR was aimed
at v26.06.7-blake2b, which is still at 893f767e8; since .4 and the
unified signing work are on blake2b-unified, I have rebased onto that and
retargeted the PR. And the series is fourteen commits now rather than five, the
extra nine being your two review points and what running the python suite
found.

Both of those mean your inline comments now sit on commits that no longer
exist, and GitHub will show them as outdated. Nothing in them was dropped: I
have replied to each in place and named the commit that answers it.

@paulscode

Copy link
Copy Markdown
Author

The PR said the suite was not run and that files still assert lnbcrt. It is
run now, and it was worth doing: it found three things wrong with this series
that review had not, and all three are pushed.

The fixture had chain_hash updated for this chain and bip173_prefix left as
bcrt, and about a dozen tests were using bip173_prefix as a stand-in for
the invoice currency. On Bitcoin those are the same string so nothing noticed.
Here they are deliberately different: addresses are unchanged, bcrt1... as
before, and invoices are lnblakert. The fixture gains lightning_hrp and the
tests that meant the invoice prefix use it. The ones that genuinely check an
address keep bip173_prefix.

Every test that loads a canned database from an older release failed, because
such a wallet is stamped with block 0 and this build will not adopt one on
--database-upgrade alone, which is the behaviour your review asked for.
Twenty-nine call sites across eight files now pass
--restamp-wallet-for-this-chain. That number is a fair measure of how much of
the suite would have failed for a single reason, and of how little use the
series was without running it.

And a successful restamp was logged at log_broken, which prints **BROKEN**.
That alarms an operator who did exactly what they were told to do, and your own
test framework treats any **BROKEN** in a node's log as a failed run, which is
how it surfaced: the canned-database tests started passing their migration and
then errored in teardown. It is log_unusual now. The two refusals above it
keep log_broken, since each precedes a deliberate exit.

tests/test_invoices.py is 24 passed, 1 skipped, nothing failed. Five tests
across the suite are skipped with the reason attached: their fixtures are
signed BOLT 11 invoices for other chains, either the spec's mainnet vectors or
regtest invoices hand-made for cases a node cannot generate on request, such as
an invoice with no payment secret. Changing a prefix invalidates the signature
and the signing keys are not available, so they cannot be carried over. The
spec vectors are still checked in common/test/run-bolt11 against a
chainparams entry that keeps bc.

It also found that an invoice for another chain produced four different errors
depending on which way it arrived, two of them unhelpful and one useless:

  bolt11 decode   Invalid bolt11 invoice: Prefix bcrt is not for regtest
                  (expected blakert)
  decode command  Unknown chain bc: invalid token '"lnbc1..."'
  listinvoices    Invalid invstring
  checkrune       Not permitted: Invalid invoice: Unknown chain bcrt

That is fixed too. Unknown chain bc is true and tells someone who pasted a
Bitcoin invoice nothing they did not know, and the old prefix is already
recorded as legacy_lightning_hrp, so it is named now: the prefix belongs to
the SHA256d chain, this node follows another, and here is its prefix. And
listinvoices was discarding the decoder's reason entirely while pay.c two
files over had always included it. Both do now.

I have also made the PR description more precise on one point. It said
decode recognises a Bitcoin invoice as one so the refusal is what the user
sees, which read as a stronger claim than the code supported. The decoder's
reason is surfaced, but inside the standard command_fail_badparam envelope,
string: <reason>: invalid token '<invoice>'. With the reason improved above
that now names the chain; before it said Unknown chain bc, which was true
and not much use.

@paulscode
paulscode force-pushed the blake2b-chain-identity branch from f2c35e8 to 00c7b33 Compare September 16, 2026 01:45
Comment thread wallet/wallet.c Outdated

*known = false;

if (chainparams->chain_hash_block_height == 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I ran this against a real mainnet wallet rather than a lab one, and the no-flag path cannot fire there.

The wallet is a StartOS node that has been on this chain since before activation. Its blocks table holds 20,991 rows spanning 504,605 to 972,349, and there is a hole from 961,628 to 971,901. The nearest row below the activation block is twelve blocks before it, and 961,640 is absent, so wallet_followed_this_chain() returns known=false and the decision falls through to the flag.

That hole is the fork itself. The pre-fork build stalled where it could not parse the 164-byte header, and the fork-aware build resumed near the tip rather than backfilling. So the activation block is precisely the block that a node which lived through the activation is least likely to hold, which is the population the no-flag restamp was written for.

The refuse path is unaffected and still correct: a continuously synced wallet on the SHA256d chain does have 961,640 with a different hash, and is refused. It is the accept path that almost never fires.

The consequence is the one your earlier reply was aimed at. On cln-startos the guard is not open, it is closed, and closed for every node rather than only for ambiguous ones: --restamp-wallet-for-this-chain is not passed there, so upgrading to a build carrying this series makes lightningd refuse to start until the package adds it. That is fixable on my side and I will add it, but it should be a deliberate decision rather than a surprise, and the release notes need to say so.

Worth considering whether the question can be asked of the chain instead of the wallet. You noted wallet_sanity_check runs fourteen lines before setup_topology, so there is no backend yet. Comparing any block the wallet does have against what the backend reports at that height would answer it for every wallet rather than only for ones holding a specific block, and the wallet here has 20,991 of them to choose from.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You are right, and the evidence is better than the wallet you happened to have.
I went looking for whether the hole is particular to that node or structural,
and it is structural.

setup_topology() sets blockscan_start = wallet_blocks_maxheight() and
reduces it by --rescan, so this build resumes from the highest block it holds
and walks forward. Nothing backfills. And
wallet_blocks_contig_minheight() exists precisely to find "the first block in
the last run of blocks", so a non-contiguous blocks table is an ordinary state
this codebase already expects. I was checking for one specific row in a table
that is designed to have holes, and I picked the row that a node which lived
through the activation is least likely to hold, for the reason you give: the
pre-fork build stopped at the header it could not parse.

So the accept path was near-dead for exactly the population it was written for,
and I had built a guard that is closed rather than open. That is worse than the
bug it replaced, because it is silent about it until startup fails.

I took your suggestion and asked the chain instead.

wallet_sanity_check() keeps the two cheap answers, since they need no backend:
the wallet holds the activation block and it matches, so restamp; it holds it
and it differs, so refuse. Where the wallet cannot say, it now defers rather
than falling to the flag. settle_deferred_restamp() runs between
setup_topology() and begin_topology(), which is the window where bitcoind is
up and no blocks have been scanned yet, and it takes the highest block the
wallet holds above the fork
, which is a height where the two chains differ,
and asks what this chain has there. Equal restamps, different refuses, and only
a wallet holding no block above the fork at all still needs the operator.

For your wallet that is a block around 972,319 rather than 961,640: the height
is whatever survives the rollback setup_topology() does to blockscan_start,
which is your highest block less --rescan. Either way it is far above the
fork, it is chain-specific, and it answers without a flag. It answers for any
wallet holding any block above the fork, which is what you were pointing at.

The rollback is also what makes the question safe to ask there.
setup_topology() waits for the backend to reach blockscan_start before
rolling the wallet back to it, so by the time this runs, the highest block the
wallet holds is one the backend is known to have. It is not asking the backend
about a height it may not have reached.

Two things I owe you about it.

The refuse path is stronger than before, not weaker: it no longer depends on the
wallet holding one specific block, so a SHA256d wallet with a hole across the
activation is now caught where previously it fell through to the flag.

And I would rather tell you what it took to get right than present it as though
it arrived working. Writing it was quick; making it correct needed four
findings, every one from running it rather than reading it:

  • struct wallet is allocated with tal(), which does not zero, and I added
    restamp_pending without initialising it. A fresh node read the garbage as
    true, went down the deferred path and refused to start.
  • The wallet read ran outside a db transaction, which db_prepare_v2 refuses.
  • The query context was tmpctx, which the io_loop frees, taking the request
    and its callback with it. The node hung at startup, waiting for a reply that
    could not arrive. setup_topology() keeps a local context and says why; I
    had read that comment and not applied it.
  • The blkid the backend hands the callback is in the opposite byte order to
    the one the wallet stores, and every other caller in that file ignores it and
    derives the id from the block. Comparing against it reversed the answer, so
    the first working version refused a wallet that had followed this chain.

That last one is the one I would have shipped. It fails in the safe direction,
it looks right in review, and the two hashes in the log are byte-reverses of
each other, which is easy to read past.

So I built a test rather than reasoning about it. Giving the lab's regtest an
activation height makes the deferred path reachable off mainnet, and a wallet
aged to match yours (stamped with block 0, no activation block, blocks above
the fork) now exercises both answers:

  wallet blocks agree with the chain   restamped with no flag, node starts
  wallet blocks differ from the chain  refused, naming both hashes and the height

On the packaging consequence: with this, cln-startos does not need to add the
flag for a node that lived through the activation, which is the population that
would have hit it. What still needs it is a wallet with no block above the fork
at all, which is a wallet created after activation and never synced. I would
still call that out in the release notes rather than let anyone meet it cold.

Comment thread wallet/wallet.c Outdated
i == 0 ? GOSSIP_STORE_FILENAME
: GOSSIP_STORE_FILENAME ".corrupt");
if (unlink(store) == 0)
log_broken(w->log, "Removed %s: its "

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This one is still log_broken, on the success path, which puts back what commit 12 removed.

fixtures.py:735 collects any log line containing **BROKEN** and fixtures.py:590 maps that to a test error, so a restamp that removes a gossip store fails the run even though everything worked, and the operator who did exactly as instructed sees **BROKEN** twice if both store files are present. Same reasoning as the message above it, which you moved to log_unusual for this exact reason.

log_unusual reads right here: removing the store is expected and is explained by the line above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Correct, fixed. fixtures.py:735 collects **BROKEN** and fixtures.py:590
turns it into a test error, so a restamp that removed a store failed the run
while doing exactly what it was supposed to, and an operator saw **BROKEN**
once per store file.

It is log_unusual now, and it moved: the restamp body is extracted into
wallet_restamp() so the deferred path above can call it too, which is what
stopped there being two copies of this to keep in step.

@paulscode

Copy link
Copy Markdown
Author

Thank you for the pointer. doc/unified-sighash.md is in
bitcoinknots/bitcoin, not here, which is why I could not find it: I had been
grepping this repository's history for it on the strength of the release note.
Worth linking from BLAKE2B-UNIFIED-PROPOSAL.md, since anyone implementing
against this fork will look here first and conclude, as I did, that it does not
exist.

It is a complete specification of the digest and I have now checked our
implementation against it rather than against your source. Lightning Fork's
SIGHASH_UNIFIED lives in a Go implementation, in our fork of btcd's
txscript, and it reproduces all 166 of the vectors in src/test/data/unified_sighash.json, from a copy that
is byte-identical to Knots' own (sha256 5c5e95fc1ab8ef9ce6b3cb6e...).

That may be worth something to you. The document says three implementations
agree on the vectors and is candid that this "does not establish that the design
is correct, since all three share an author". Ours is a fourth, in another
language, by another author. It does not close that gap, since it was written
with your source available rather than from the document alone, but it is one
more independent reading than there was.

What the document does not cover is the part I actually had to guess: it
specifies the digest, and says nothing about Lightning. Which hash type belongs
on a commitment transaction, on a second-level HTLC transaction under anchors,
or on a cooperative close, is not in it, and that is the layer where two
implementations that almost agree would produce channels that cannot be closed.
I matched channel_type_sighash() and full_channel.c and I get 0x21 on the
commitment and the cooperative close, and 0xa3 on second-level HTLC
transactions on an anchor channel. Your release notes confirm the 0x21
independently. The 0xa3 is only from reading full_channel.c, so that part
is currently an agreement between my reading of your code and your code, which
is the kind of agreement that holds right up until it does not.

So the narrower question stands: is there anything written down for the channel
type, or should I draft it? I am happy to write what I implemented as a starting
point and have you correct it, and it would sit naturally next to
doc/unified-sighash.md as the Lightning half.

@kwsantiago

Copy link
Copy Markdown

Keeping chain_hash as Bitcoin's genesis: unified_sigs in channel_type already isolates. Sorry for the churn on the restamp work.

lightning-blake2b/bolts#1

@paulscode

Copy link
Copy Markdown
Author

Adopted, and I think it is the better design. Isolating at chain_hash asked
every node and every wallet to agree a new identifier for something that only
ever mattered in two places; option_unified_sigs in channel_type and a
height floor on gossip cover both of those directly.

Where that has actually got to, since "adopted" can mean two things and you
should have the real one. Lightning Fork advertises the shared genesis hash in
its tree, along with the gossip height rule and option_blake2b on the even
bit. It is not released yet. The builds on mainnet are v0.21.3-beta-blake2b.9
and earlier, which still carry the chain_hash of their own, and they cannot
simply be upgraded into the new one: lnd keys channels by chain hash in its
database, so a node with channels opened under the old value fails to start
against a build using the new one, with no chain bucket exists. That needs a
database migration, which I am writing and which has to be right before it
ships. So the design is settled on my side and the rollout is a few days
behind it.

I mention it for two reasons beyond honesty about my own state. It is the
concrete cost of having shipped a chain_hash of one's own, which is an
argument for the reversal that neither of us made at the time. And it is the
same trap for anyone who applied my Core Lightning series and opened channels:
your own builds never took the synthetic value, so you are clean, but the
series would have put them in exactly this position.

On the restamp work, you were right and my first instinct was wrong. I had
started writing that the reversal makes it more load-bearing, on the reasoning
that with nothing left in the chain identity to tell the chains apart, the
wallet's own recorded history becomes the last thing that can notice. I went to
measure that before sending it, and it does not hold up.

The check is reached through one gate in wallet_sanity_check: the wallet is
stamped with block 0, and this chain's chain_hash is something other than
block 0. The reversal makes the second half false permanently, so after it no
wallet reaches the check at all. It was a migration off the synthetic
chain_hash rather than a standing guard, and the reversal is exactly the
thing that retires it. Two smaller points fell out of the same reading: the
history comparison behind the gate is mainnet-only, since
chain_hash_block_height is 0 on every other network and
wallet_followed_this_chain returns without an answer there, and with the gate
closed there is no refusal of any kind. I synced a wallet on a BLAKE2b regtest,
stopped it, and started the same wallet against a SHA256d regtest. It rolled
back to blockscan_start, rescanned the other chain to its tip, and said
nothing.

So I will withdraw that series rather than re-motivate it. What I do not think
goes away is the gap it was pointed at, and I think the reversal moves it: the
question worth asking at startup is not which chain the wallet followed but
which chain the backend is on. Lightning Fork answers that one by reading the
header at the activation height and refusing if it is 80 bytes rather than 164,
which touches chain_hash nowhere and so came through the reversal unchanged.
Core Lightning cannot do it that way, since it takes the backend's word for
blocks. The cheap equivalent is one getblockhash 961640 at startup, once the
backend is past that height, compared against the activation block id you
already checkpoint. No flag and no migration semantics: either the backend is
this chain or it is not.

The rework, then: drop the synthetic chain_hash commit, split the invoice
prefix out of it, drop the restamp series, keep the BOLT 11 error-message fix,
and add the gossip height rule to match the spec. That takes fifteen commits
down to a handful. I will offer the backend check separately if you want it,
since it is a new idea rather than a rework and it should be yours to refuse on
its own terms.

One more reason to withdraw rather than defer, which I found by tripping over
it. My lab still had a Core Lightning image built from the series, and since
Lightning Fork moved to the shared genesis the two will not peer: "no common
chain", mine listing 0f9188f1...2206 and theirs 2594d57b...ab1a. That is my
old design meeting my new one rather than anything of yours, and it is what
would happen to anyone who shipped the series now. Your unmodified build peers
with this node without a change to either.

The one thing the spec does not cover yet

This is the part I would most like settled, and I have stopped being able to
call it theoretical. Our two implementations cannot currently pay each other.

I put Lightning Fork on one side and your build on the other, unmodified, at
24d027310. They peer. They agree channel_type [12,22,70]. They gossip. They
close cooperatively and by force, and both closes put 0x21 in the witness,
half of each signature computed by your node. Everything below the invoice
works. Then:

lnd  -> cln:  invoice is for the SHA256 chain (prefix "lnbcrt500u"), not
              the BLAKE2b chain this node follows (expected prefix
              lnblakert)
cln  -> lnd:  Invalid bolt11 invoice: Prefix blakert is not for regtest

Each side refuses the other's invoice on the prefix, before a route is
considered. Mine says lnblake, yours says bc, and until one of us moves
that is where it stops. I am not claiming my choice is the right one; I am
saying the disagreement is live and is not visible anywhere except at the
moment a user pastes a string.

I had this passing earlier, which is worth admitting because of how. The Core
Lightning side then was built with my own prefix patch on it, so both ends said
lnblakert and the payment went through. That measured my patch talking to
itself. Against your build with nothing of mine applied, it does not.

channel_type keeps the channels apart and the height floor keeps the gossip
apart, but an invoice is neither: it is a string a human pastes into whichever
wallet they happen to have open.

I have a second reason to care, which is worth stating because it is evidence
rather than an opinion. I have built a bridge that swaps between the two
chains, working against regtest nodes rather than in production so far, and it
decides which direction a swap runs by handing the invoice to each of its two
nodes and seeing which one can read it. That works only because the prefixes
differ. If both are lnbc, both nodes decode both invoices and there is
nothing left to route on. Anything else that has to tell the two apart from an
invoice alone is in the same position.

The next part may sound like a claim about which chain is Bitcoin, but it is not
meant to be. lnbc is already deployed. Every wallet in the field parses it as
the chain that did not upgrade, and those wallets will not be updated. If this
chain also uses lnbc, an invoice from here pasted into one of them is parsed,
accepted and paid on the other chain, or fails in a way that gives the user no
idea which of two chains was at fault. That is an argument from compatibility
with software that predates the change, not from seniority: the same argument
gave option_unified_sigs a new feature bit rather than redefining an old one,
and for the same reason.

The refusals above are between our two builds, which both know this chain
exists. The case the deployed software is in is the other one, and it is worth
seeing separately. An invoice minted here, handed to a stock lnd on the SHA256d
chain that has never heard of any of this:

invoice not for current active network 'regtest'

It refuses, which is the outcome that matters, and it has no way to say what it
refused, because it predates the change and always will. That node will never
be updated. With a shared prefix it would not have refused at all: it would
have decoded the invoice, found it well formed, and paid it on the other
chain.

On the specific string, I have no strong attachment. Mine is lnblake and what
I would defend is the requirement rather than the letters: distinct from lnbc,
and not visually close to it. Something like lnbcb reads as the more
conciliatory choice but I would argue against it on safety grounds, since one
character from lnbc across two chains that share an address format and all
their pre-fork history is an invitation to mis-sends. If you would rather have
a different string that satisfies those two properties, say which and I will
use it; the requirement is what I care about.

And the same question, worse, in BOLT 12

Adopting the reversal turned this up, and it is the harder half.

An offer that omits offer_chains is for Bitcoin mainnet by the spec's
default. With chain_hash back to the shared genesis that default is now this
chain, so an offer minted here and an offer minted on the chain that did not
upgrade name the same chain and are otherwise the same bytes. A BOLT 11 invoice
at least carries a prefix; lno is lno either way.

I ran it rather than leaving it as an argument, because it is the kind of claim
that is easy to get backwards. Two BOLT 12 nodes, one following each chain,
each minting an offer and decoding the other's. Both reported the other chain's
offer as valid and for their own chain, both named the same chain_hash, and
neither warned about anything.

That was on regtest, where both implementations write offer_chains out
because the spec's default is mainnet. On mainnet both would omit the field and
both would default to the same chain, so the ambiguity is identical and only
its location in the bytes differs. I mention it because the run does not
literally exercise the omitted-field path, and I would rather say so than have
you find it.

Nothing here needed a bug to go wrong, which is what separates it from the
prefix question. There, two implementations disagree and each refuses what it
does not recognise. Here they agree, correctly, and the agreement is the
problem: following the spec exactly, neither can tell these offers apart,
because as of the reversal there is nothing in an offer that distinguishes
them.

I hit the other half of it as a plain bug while adopting the change. My decoder
read an absent offer_chains as naming nothing and therefore not this chain,
which was right for exactly as long as this chain had a chain_hash of its own.
Most offers omit the field, so the node would have refused the common case while
its own validator, which applies the default, accepted the same offer. Anyone
who made the same reasonable assumption has the same bug waiting.

How bad it is, precisely. Less bad than it first looks, and not harmless.
A node whose channels are all on the other chain is not reachable from here at
all. It cannot peer with this node, since it does not set bit 68 and this node
does, and even if its gossip arrived by some other path the height rule drops
its pre-activation channels and its post-activation ones fail the funding
output lookup. So fetching an invoice for its offer fails with no route and no
money moves. The failure is confusing rather than expensive.

The case that does cost money is a node with channels on both chains, which is
plausible precisely because the chains share addresses and all their pre-fork
history. Its offer is fetchable here, the invoice comes back, and the payer
pays on this chain for something the merchant advertised in the other chain's
terms. Same hazard as reusing one seed across both, and it arrives through a
string a user pasted rather than through anything they configured.

What I would propose. The same shape as the BOLT 11 fix: use the field that
already exists for naming chains, with a value the two chains do not share.
That field is offer_chains, and the obvious value is the id of the first
BLAKE2b block, 961,640.

I am aware of how that sounds, so to be explicit: I am not reopening
chain_hash. Your two rules cover the two places you were addressing, and I
think they cover them well. This is a third place, and it needs a name rather
than an identity. The activation block already is one: it is checkpointed in
Knots, and between the two of us and your live-node decode its id has now been
verified three times. It is also the height the gossip rule you just specified
turns on at, so naming it here adds no new constant to anything.

The reason I would do it this way rather than leave offers ambiguous is that it
changes which way things fail. With a shared value, a dual-homed merchant's
offer is paid on whichever chain the payer happened to be on, silently. With a
distinct value, a node on the other chain sees a chain it does not know and
refuses, this node sees an offer naming the shared genesis and treats it as the
other chain's, and nobody pays anything by accident. Neither side has to be
right about which chain is Bitcoin for that to work.

I have not implemented it. Minting offers that name a chain your build does not
recognise would break fetching between our two implementations, and doing that
before you have had a chance to disagree seems like the wrong order. Say the
word and it is a small change at my end.

BOLT 3

You have written it yourself while I was measuring, which is better than my
drafting it, so this is a review rather than an offer. The detailed comments
go on the bolts PR; what belongs here is what I can attest to.

I do have the independent implementation your note asked for: a Go one, in my
fork of btcd's txscript, which reproduces all 166 vectors in
src/test/data/unified_sighash.json from a byte-identical copy.

And I stopped taking my own word for the second-level value. Until this week
0xa3 was my reading of full_channel.c agreeing with my implementation of my
reading of full_channel.c, so I went and got it on chain. A channel between
Lightning Fork and blake2b-unified at 24d027310, with nothing of mine
applied, carrying an HTLC that my node offered and then let time out. The
HTLC-timeout transaction confirmed with

<> <remotehtlcsig ...a3> <localhtlcsig ...21> <> <witness script>

Your node computed the first of those and sent it in commitment_signed; mine
only appended the byte. So the value is attested by both of us now rather than
by one of us twice.

The run also corrected me, and the same correction applies to your text, which
is the one substantive thing I have to say about it. I had been writing "the
second-level HTLC signature is 0xa3"; the transaction carries two signatures
and on an anchor channel they are not the same. Only the half pre-signed by the
peer needs SIGHASH_SINGLE|SIGHASH_ANYONECANPAY, so that fees can be attached;
the broadcaster signs its own half SIGHASH_ALL, which is 0x21. Both of our
implementations already do this correctly. What I would change is the sentence,
and the way to make it hard to get wrong is to state the rule per signature,
"the hash type BOLT 3 already specifies for that signature, plus the bit",
rather than per transaction. I have wording for that and will put it on the PR.

Worth noting for the thread, since it is why I bothered getting it on chain at
all: a commitment signature carries no hash type on the wire. Each side
computes the digest from what it believes the hash type to be, so two
implementations that disagree see only "your signature is invalid", with
nothing to say which reading was wrong.

One more thing I changed on my side

Your BOLT 9 text says option_blake2b is bit 68, even, in init and
node_announcement, and MUST NOT be sent as 69. I was sending 69, and the
comment in my code explaining why said that chain_hash was the real separator
and the bit was a courtesy. That reasoning did not survive the reversal and I
had not noticed until I read your text: with the hash shared, an odd bit is
ignored by exactly the peer that needs to go away, and what was actually
keeping the chains apart on my node was a heuristic that drops any peer sending
no networks TLV. That is the rule you objected to in your review of my series,
on the grounds that it drops every cln-application dashboard, and you were
right about mine for the same reason you were right about yours.

Changed to 68, and the heuristic now defaults to off, since sending that TLV is
optional and bit 68 does the job it was standing in for. A stock lnd hangs up
on my node by itself with "unknown required features: [68]", including against
a node deliberately configured to keep silent peers, so the bit is carrying it
rather than the heuristic. Your rationale section is right, and reading it
caught a real gap in my implementation.

@paulscode
paulscode force-pushed the blake2b-chain-identity branch from 3505090 to efd4c2d Compare September 17, 2026 18:43
@paulscode paulscode changed the title Lightning identity for the Bitcoin BLAKE2b chain: chain_hash, invoice prefix, BOLT 12 chains, init networks Lightning identity for the BLAKE2b chain: the invoice prefix and the gossip floor Sep 17, 2026
@paulscode

Copy link
Copy Markdown
Author

Reworked and force-pushed: fifteen commits down to seven, as described above.

Gone: chain_hash, the BOLT 12 implicit-chain change, both connectd commits,
and the six-commit wallet restamp series, whose gate cannot fire once
chain_hash equals block 0. Kept: the invoice prefix, split out of the commit
that also changed chain_hash, and the BOLT 11 error message. New: the gossip
floor at the activation height, placed where the BOLT 7 text in
lightning-blake2b/bolts#1 places it.

Two things from building and running it that are worth having in the thread.

The series closes the interop break I mentioned earlier. Against Lightning Fork
in a regtest lab, without it the two peer, agree channel_type [12,22,70],
gossip and close both ways with 0x21, and cannot pay each other, each
refusing the other's invoice on the prefix. With it, the same run pays in both
directions and both closes still carry 0x21.

And running make check-units, which I had not done against the old series,
found a crash that was in it too. The commit that says which chain a foreign
prefix belongs to read chainparams->legacy_lightning_hrp while decoding, and
decoding does not require a configured network: the daemon always has one, but
fuzz-bolt11 does not, so that was a null read and the target segfaulted.
Guarded now. check-units fails on exactly one target, fuzz-open_channel,
which fails the same way on 24d027310 with nothing applied.

Not yet run: the full python suite. The prefix reaches further into the tests
than it looks, since the bookkeeper's coin_type is the lightning prefix
rather than the address prefix.

BOLT 12 is still open and deliberately untouched, for the reason in the body.

`chain_hash` is the genesis hash both chains share, deliberately, so nothing in
a BOLT 11 invoice says which of the two it is for. The prefix is the only place
that can, and it is the last thing a user sees before paying.

    mainnet   lnblake        testnet4  lntblake
    signet    lntbsblake     regtest   lnblakert

An invoice for the chain that did not upgrade is refused here, and an invoice
minted here is refused there. That second half is the point: those nodes
predate this chain and will never be updated, so a shared prefix would mean an
invoice from here is decoded, found well formed, and paid on the other chain.

`legacy_lightning_hrp` keeps the prefix each network had before, because
refusing an invoice is not the same as failing to recognise one. `decode` still
pulls `lnbc` and reports it as an invoice for another chain, rather than
falling through as an unknown string, and the bookkeeper's older events were
written with it.

testnet3 keeps `tb` and is left alone: it shares that prefix with testnet4 in
this table, and only testnet4 is a chain this fork runs on.
Running the python suite turned up four different errors for one
condition, an invoice for another chain, depending on which way it
arrived. Two of them were unhelpful and one was actively useless.

Unknown chain bc is true and tells a user who pasted a Bitcoin invoice
nothing they did not know. On a chain that took a new prefix the old one
is the commonest thing anyone will paste, and it is already recorded as
legacy_lightning_hrp, so name it: the prefix belongs to the SHA256d
chain, this node follows another, and here is its prefix.

Invalid invstring from listinvoices discarded the decoder's reason
entirely. pay.c two files over has always included it. Now both do.
…hanged

A funding output from below block 961,640 exists for nodes that did not upgrade
too, and its spend may happen where this node cannot see it, so a channel
announced against one would sit in the graph forever with nothing able to
remove it.

The chain_hash check just above cannot do this job. Both chains carry the
genesis hash they share, deliberately, so it does not tell them apart; it only
tells either of them from some third chain. This is the other half of what
replaced giving the chain a chain_hash of its own, the first half being
option_unified_sigs inside channel_type.

A channel funded after the activation on the other chain is already refused,
because its funding output does not exist here and the txout lookup fails. Only
the ones funded before it need a rule.

The height is a chainparams field rather than a constant so that the rule is
off on every network that never changed its proof of work, where a floor would
refuse every announcement there is.

Placed exactly where BOLT 7 puts it, after the chain_hash clause and before the
one about ancient channels, and it applies to this node's own announcements as
well: a channel of ours funded before the activation is in the same position.
A prefix is the only thing in a BOLT 11 string that says which chain it is for,
and the constants live in a table of per-network initialisers where a mistake
is easy to make and invisible until an invoice is refused or, worse, accepted.

Checks the current and legacy prefix of every network this fork runs on, that
no two networks claim the same prefix, that each is reachable by name through
chainparams_by_lightning_hrp, and that a legacy prefix never equals the current
one, which would make the "this is for another chain" message nonsense.

Also pins the activation height the gossip rule compares against: set on the
chain that changed its proof of work, zero everywhere else. Zero is what turns
the rule off, and a stray non-zero value on a chain that never moved would
refuse every channel announcement it ever saw.

There is no chain_hash here on purpose. It is the genesis hash both chains
share, so there is nothing about it left to get wrong.
…_hash

The previous version of this document specified a chain_hash of this chain's
own and said that was what kept the two chains apart. That design is withdrawn.
chain_hash is the genesis hash both chains share, and the chains are separated
in the four places it actually matters: option_blake2b at init, a gossip floor
at the activation height, option_unified_sigs inside channel_type, and the
BOLT 11 invoice prefix.

Rewritten around those four, one section each, with the hash types stated per
signature rather than per transaction, since a second-level HTLC carries two
and on an anchor channel they differ. Section 6 is the BOLT 12 gap, which
nothing covers and which the old design did cover, said plainly rather than
left out. Section 8 is the change itself and what to do if the old values were
already implemented.

Kept identical to Lightning Fork's copy except where an implementation detail
differs, which is the point of writing it down at all.
The PR said the pytest suite was not run and that files still assert
lnbcrt. This runs it, which turns a guess into a list: five tests fail,
all for the same reason, and none of them for a reason that can be fixed
by editing a string.

Their fixtures are signed BOLT 11 invoices for other chains, either the
spec's mainnet lnbc vectors or regtest lnbcrt invoices hand-made years
ago for cases a node cannot generate on request, such as an invoice with
no payment secret. Changing a prefix invalidates the signature, and the
keys that signed them are not available, so they cannot be carried over.
They are skipped with the reason attached rather than quietly deleted.

The spec vectors are not lost. common/test/run-bolt11 decodes them
against a chainparams entry that keeps the bc prefix, which this series
already added and already runs.

Running it also found something worth fixing separately, which is why the
exact messages are recorded here. One condition, an invoice for another
chain, produces four different errors depending on the path:

  bolt11 decode  Invalid bolt11 invoice: Prefix bcrt is not for regtest
                 (expected blakert)
  decode command Unknown chain bc: invalid token '"lnbc1..."'
  listinvoices   Invalid invstring
  checkrune      Not permitted: Invalid invoice: Unknown chain bcrt

The first is the message this series intended. The second is the token
fallthrough the PR text claims it avoids, so that claim is wrong as
written. The third tells the user nothing at all. Making these one
message is a follow-up rather than part of this commit, but it should not
go unrecorded: an operator who pastes a Bitcoin invoice should get the
same answer wherever they paste it.
Changing the BOLT 11 prefix breaks every test that spells one out, and there
are more of them than there look: the bookkeeper's coin_type is the lightning
prefix rather than the address prefix, so its assertions move too.

The pytest chainparams fixture gains `lightning_hrp`, which is what the tests
should have been reading all along instead of `bip173_prefix`. Addresses are
unchanged on this chain and invoices are not, so the two are no longer the same
string and a test that conflates them passes for the wrong reason.

Nothing here touches chain_hash or the wallet stamp. An earlier version of this
series changed both and carried a `restamp-wallet-for-this-chain` option into
these tests; that series is withdrawn and the option does not exist.
@paulscode
paulscode force-pushed the blake2b-chain-identity branch from efd4c2d to abd909c Compare September 18, 2026 01:57
@paulscode

Copy link
Copy Markdown
Author

Ran the python suite against this, which the previous series never had, and it
found two bugs. Both were in that series too. Pushed as fixes; the failure set
now matches plain 24d027310 on the files the prefix reaches.

The bookkeeper could not read its own history. It decodes the BOLT 11
strings it has stored, with no chain check at all, and those were written with
the old prefix. chainparams_by_lightning_hrp only knew prefixes in current
use, so the decode failed and the migration aborted:

plugin-bookkeeper: failed to parse bolt11 lnbcrt1...: Prefix bcrt is the
SHA256d chain's; this node follows regtest, whose prefix is blakert

That is accounting data lost on upgrade, for every payment made before it. The
lookup now falls back to the prefix a network used to carry, in a second pass
rather than a wider first one, so a prefix still in use always wins: testnet3
uses tb today and testnet4 used to, and an invoice saying tb is testnet3's.

It does not make an old invoice payable. The path that cares which chain an
invoice is for passes must_be_chain and compares against lightning_hrp
directly rather than coming through the lookup, so lnbcrt is still refused
when paying and now decodes when reading.

And two bookkeeper tests asserted the prefix as a literal, 'currency': 'bcrt' in eighteen places, which the earlier test commit missed because it
only touched the sites that already went through the fixture.

On method, since the numbers look alarming on their own: those files fail 85
tests on plain upstream, before any of this. The question I could answer is
whether the series adds to that, and it does not. One test fails under -n 4
and passes alone on both, which is flakiness.

Still not run: the rest of the suite, beyond the files the prefix reaches.

Comment thread common/test/run-bolt11.c
@@ -281,6 +281,24 @@ static const u8 **fallbacks(const tal_t *ctx,
return addrs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bc1qjwqr7hlpsf0f6mkf5uqqtfa4prj2udnux0k7j9

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants