Map what a block number identifies
In go-ethereum v1.16.0, a block height is an index into more than one data set. The raw database schema defines a number-to-canonical-hash key and a distinct header key containing both number and hash. The chain accessors expose that distinction: one read obtains the selected canonical hash, while another enumerates stored header hashes at the same number.
Scroll the diagram horizontally
The model explains why two records at one height are not automatically corruption. Forks and reorganizations can leave more than one stored header, while a separate mapping selects the current chain. The rawdb test that writes multiple hashes per height checks the broad enumeration; the canonical iteration test checks the selected mapping. These are upstream test assertions, not evidence that a production reader chose the wrong branch.
The vulnerability question appears only at the next edge: does a consumer that requires canonical state read the canonical mapping, or does it fall back to the broader header set without resolving branch identity? That question cannot be answered by the database layout alone; it needs the specific consumer and a forked execution record.
Two questions in one file
Go Ethereum v1.16.0 stores the selected canonical hash for a block number through WriteCanonicalHash:
if err := db.Put(headerHashKey(number), hash.Bytes()); err != nil {
The same accessors_chain.go, lines 36–76 also has ReadAllHashes. That function iterates a prefix for the requested height and collects hashes from canonical and reorganized branches. The two APIs answer different questions: which hash is canonical here? and which block hashes have been stored here?
A height is incomplete
A height is not enough to identify a branch-local value. A client that wants the accepted chain needs a canonicality decision; a client that analyzes forks needs the broader set. The data model makes this distinction explicit, which is useful when reading any fallback from a branch-specific lookup to a global index.
The quoted write establishes a canonical mapping. Testing whether another path could change it incorrectly requires the writer’s call path and a forked execution trace. The immediate review question is which index a caller needs.
Forked-state control
Create a common parent with two different children at the same height. Import both, select one as canonical, and compare ReadCanonicalHash with ReadAllHashes. Reverse import order and repeat. The result shows when selection changes relative to storage. A downstream impact would require a separate reader that makes a decision from the wrong answer; the two API definitions alone do not establish one.
The keys answer different questions
At v1.16.0, ReadCanonicalHash first tries the ancient hash table and then the ordinary canonical number-to-hash key. WriteCanonicalHash writes that selected mapping. By contrast, ReadAllHashes iterates headerKeyPrefix(number), accepts keys of exactly len(prefix)+32, and extracts each hash suffix. Its own comment explicitly includes canonical and reorganized forks.
prefix := headerKeyPrefix(number)
it := db.NewIterator(prefix, nil)
for it.Next() {
if key := it.Key(); len(key) == len(prefix)+32 {
hashes = append(hashes, common.BytesToHash(key[len(key)-32:]))
}
}
There is no canonicality predicate in that loop. That is appropriate for an “all hashes” API. It would be the wrong answer for a caller whose contract asks for the selected chain unless that caller filters or resolves the result elsewhere.
The repository’s TestHashesInRange writes multiple distinct headers at each height. It asserts that ReadAllHashes(db, 10) returns ten hashes and ReadAllHashes(db, 16) returns zero. A separate TestCanonicalHashIteration populates canonical mappings and tests range selection. These are upstream assertions, not this lab’s execution result. Together they make the intended difference between a canonical mapping and a height-wide enumeration inspectable.
A forked fixture needs three observations
For a genuine chain reader, record the selected canonical hash at height H, the complete stored hash set at H, and the downstream value chosen by the reader. Merely showing two stored hashes is not a failure: forks are expected to coexist. The decisive question is whether a reader that promises canonical state picks a side-branch value after a normal fork, reorganization, or degraded lookup.
Run the fixture twice with opposite import order. If its result changes only with storage order while canonical selection stays fixed, inspect how the reader chooses among hashes. If its result tracks the canonical mapping, the broader index may be harmless context.
Branch scope cannot be inferred from a number
Consider a synthetic fork at height H:
| Height | Stored blocks | Canonical selection |
|---|---|---|
H-1 | Parent P | P |
H | Children A and B | A |
In this synthetic fork, canonical(H) = hash(A), while allHashes(H) = {hash(A), hash(B)}.
Both return shapes are legitimate. A function that promises a list of observed blocks should include B; one that promises canonical state should bind its read to A or explicitly explain another rule. A fallback that merely takes the first hash from allHashes(H) would need a documented ordering and a canonicality check before it could be treated as equivalent to canonical(H). The ReadAllHashes implementation returns hashes by database iteration and does not make such a guarantee in its code. An actual caller would have to be traced before attributing this behavior to a system.
The upstream test’s ten-header case is a good control for the index’s cardinality, but it does not choose one of those ten as canonical for that scenario. The canonical iteration test separately writes canonical mappings. A new end-to-end test should combine the two conditions, then show the actual reader’s output before and after a canonical switch. If the reader caches results, invalidate or measure the cache; otherwise a stale cached answer can be mistaken for an index-scope error.
Finally, impact depends on what the chosen value controls. A side-branch value in a debug view may be harmless. The same value used for finality, reward accounting, or transaction acceptance would require much stronger validation and an actual reachable decision trace. The two raw database APIs alone settle neither question.