Security · Original reporting
Set the Clock to 1970 and the Wallet Looks for Its Coins in 2149
A draft pull request opened at Lightning Labs on the morning of September 8 adds bounds checking to lnd's seed generator. The reason sits in the code comment the author wrote beside it: an unchecked type conversion could turn a wrong system clock into a recovery scan that begins a century and a quarter from now — and quietly walks past every coin the wallet owns.
At 06:54 UTC on September 8, Yong Yu — a longtime contributor to lnd, the dominant Lightning Network implementation — opened a draft pull request against the project's seed code. It is small: 174 lines added, 15 removed, across five files, most of it tests. Its title is wallet: guard aezeed birthday recovery.
Nothing about that title suggests urgency, and the desk wants to be careful here at the outset: this is a draft, it has not been merged, no maintainer has reviewed it, no security advisory exists, and there is no report anywhere of a single satoshi lost to what it describes. What makes it worth reading is the comment the author left in the code, which states the failure mode in one sentence more plainly than any advisory would have.
What a birthday is
When lnd generates a wallet it produces an aezeed — the 24-word phrase users write down. Unlike a plain BIP-39 mnemonic, an aezeed carries more than entropy. It encodes a version, the entropy, and a birthday: the day the seed was created, stored as the number of days elapsed since the Bitcoin genesis block of January 3, 2009.
That field exists to save time. Restoring a wallet means scanning the chain for addresses that belong to it, and there is no reason to scan years that predate the wallet's existence. So the birthday sets where the scan starts. lnd's own recovery documentation spells this out when describing the --reset-wallet-transactions flag, which "will reset the best synced height of the wallet back to its birthday, or genesis if the birthday isn't known."
The birthday is stored as a uint16 — an unsigned 16-bit integer, sixty-five thousand five hundred and thirty-five possible values. Counting days from January 3, 2009, that ceiling lands on June 8, 2188. As a range for a Bitcoin wallet, it is generous.
The catch is what happens to a date that falls outside it. Until this pull request, the answer was: nothing visible.
Four lines that were not there
The old code computed the birthday in a single expression. It subtracted the genesis date from the current time, divided by twenty-four hours, and converted the result to a uint16. There was no check on either end of that conversion.
In Go, converting a negative integer to an unsigned type does not fail and does not warn. It wraps. A machine whose clock reads a date before January 3, 2009 produces a negative day count, and the conversion turns that negative number into a very large positive one.
The new code refuses the input instead. The added guard rejects any creation time before the genesis date or beyond the 16-bit ceiling, returning an error that the seed creation time "is outside the representable birthday range." It runs before the seed's randomness is even generated. Above it, the author left the reasoning: "Validate before narrowing or generating randomness: a wrapped day count could silently move the wallet's recovery scan past its funds."
That is the whole story in one line, and it is the author's own characterisation, not this desk's. The word doing the work is silently.
Where a wrong clock sends the scan
| System clock at seed creation | Days since genesis | Stored as uint16 | Scan would begin |
|---|---|---|---|
| Sep 8, 2026 (correct) | 6,457 | 6,457 | Sep 8, 2026 |
| Jan 1, 1970 (unset RTC) | −14,247 | 51,289 | Jun 7, 2149 |
| Jan 1, 2000 | −3,290 | 62,246 | Jun 7, 2179 |
| Ceiling of the field | 65,535 | 65,535 | Jun 8, 2188 |
A node built on hardware without a battery-backed clock — a single-board computer, a freshly imaged appliance, a container started before network time synchronisation completes — can boot reading January 1, 1970. Generate a seed in that window and the wallet writes down a birthday of June 7, 2149.
The half of the fix that matters more
Rejecting bad dates at creation time protects seeds that do not exist yet. It does nothing for seeds already written on paper, and this is where the pull request gets more interesting than a bounds check.
The birthday is not metadata stored alongside the mnemonic. It is encoded inside it, encrypted with the entropy and the version into the same 24 words. A user cannot inspect it, cannot correct it, and cannot regenerate the phrase without moving every coin to a new wallet. Whatever date was captured at creation is the date that phrase carries for as long as it exists.
So the pull request adds a second guard, on the restore path in walletunlocker/service.go, where a submitted mnemonic is turned back into a seed. If the decoded birthday is in the future, it is set to zero — genesis — and the wallet scans the entire chain. The comment beside it is explicit about who that is for: "A bad creation clock or legacy date wraparound can leave a future birthday. Start at genesis, not today, so restoration cannot skip historical funds because of this invalid bound."
The accompanying test case is named legacyWraparound. The author is not describing a hypothetical.
The tests assert something else worth noting: when the birthday is reset, the entropy, the internal version and the recovery window all survive unchanged. Only the scan boundary moves. The trade-off is stated in the release note the pull request adds to lnd 0.22.0 — restoring such a seed "now scans from genesis to avoid skipping historical funds, which may make recovery take longer."
Slower is the correct answer. The alternative failure is a user who restores a wallet, sees a zero balance, and concludes the money is gone.
What the label does and does not mean
Within nine minutes of the pull request opening, a bot attached a severity-high label and posted an explanation. It is worth reading carefully, because it is the kind of artifact that gets mistaken for a finding.
lnd's severity bot classifies changes by which files they touch. It rated this one HIGH because walletunlocker/service.go falls into an "auth/security-sensitive" tier, and MEDIUM for aezeed/cipherseed.go. Its own analysis notes that no critical packages are involved and that the change is small. The label describes the neighbourhood the code lives in. It is not a human security assessment, and nobody at Lightning Labs has published one.
This desk flags that distinction because the same signal read the other way — machine-applied "high severity" on a wallet recovery file — is exactly the sort of thing that becomes an exploit rumour by the second retelling. There is no exploit here. There is no attacker. A wrong clock is not an adversary, and a wallet that scans from genesis finds everything it owns.
What is not established
Several things remain open, and the desk will not paper over them. Whether any user has actually generated a seed on a mis-set clock is unknown; the pull request does not cite an incident, and the desk found no report of one. Whether the draft will merge in its present shape is unknown. Whether other aezeed implementations — the format is used beyond lnd — carry the same unchecked conversion, this desk has not verified and does not assert.
What is established is narrower and still worth the reader's attention: until this change, lnd would encode an out-of-range creation date into a permanent, unmodifiable seed without complaint, and on restore it would trust that date to decide where to start looking for money.
The Take
The interesting thing about this patch is not the bounds check. It is the second half — the decision to distrust a value the wallet itself wrote down. Most defensive code guards the boundary with the outside world: user input, network data, an untrusted counterparty. A seed birthday is none of those. It is a number the software generated, encrypted and handed to its owner on a card to keep in a safe. Treating it as suspect on the way back in is an admission that the trusted path had a hole in it years ago, that the artifacts produced through that hole are immutable and already distributed, and that the only remaining move is to catch them on return. That is not a glamorous fix and it will not get a CVE. It is also the right one, and the trade-off it accepts — a slower restore for everybody in exchange for a correct restore for somebody — is the trade-off a wallet should always take. The narrower lesson is older than Lightning: an unsigned type does not mean the value cannot be negative. It means you will never find out that it was. Go converted a negative day count into a date in the twenty-second century and returned no error, because that is precisely what the language specifies it should do. The bug was not in the conversion. It was in believing the clock.