From 4f6219d5e71b9da03ffc6853d66a18d04efd53ee Mon Sep 17 00:00:00 2001 From: Guillaume Ballet Date: Wed, 29 Apr 2026 16:16:54 +0200 Subject: [PATCH 1/6] params, core: add binary transition registry + UBT transition-tree end time Introduce `params.BinaryTransitionRegistryAddress` (0x1622...1622), the system contract that exposes MPT-to-binary transition state via fixed storage slots (started, conversion progress, ended, base root). Add `ChainConfig.UBTTransitionEndTime *uint64` and the `UBTTransitionActive(num, time)` helper. While UBT is active and the header time is below the configured end (or no end is configured), state access is wrapped in a TransitionTrie that overlays the binary trie on the frozen MPT base; once headers cross this timestamp, the wrapper is dropped. Mirrors the threshold semantics of TerminalTotalDifficulty. Surface the new field in `ChainConfig.String` and the description banner, and add `ChainOverrides.OverrideUBTTransitionEnd` so the value can be patched at startup alongside `OverrideUBT`. No behaviour change yet: callers are introduced in the following commits. --- core/genesis.go | 12 ++++++++---- params/config.go | 26 ++++++++++++++++++++++++++ params/protocol_params.go | 10 ++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/core/genesis.go b/core/genesis.go index d77ea10d8c..8095b4442a 100644 --- a/core/genesis.go +++ b/core/genesis.go @@ -276,10 +276,11 @@ func (e *GenesisMismatchError) Error() string { // ChainOverrides contains the changes to chain config. type ChainOverrides struct { - OverrideOsaka *uint64 - OverrideBPO1 *uint64 - OverrideBPO2 *uint64 - OverrideUBT *uint64 + OverrideOsaka *uint64 + OverrideBPO1 *uint64 + OverrideBPO2 *uint64 + OverrideUBT *uint64 + OverrideUBTTransitionEnd *uint64 } // apply applies the chain overrides on the supplied chain config. @@ -299,6 +300,9 @@ func (o *ChainOverrides) apply(cfg *params.ChainConfig) error { if o.OverrideUBT != nil { cfg.UBTTime = o.OverrideUBT } + if o.OverrideUBTTransitionEnd != nil { + cfg.UBTTransitionEndTime = o.OverrideUBTTransitionEnd + } return cfg.CheckConfigForkOrder() } diff --git a/params/config.go b/params/config.go index 17508cbf27..00752479d6 100644 --- a/params/config.go +++ b/params/config.go @@ -468,6 +468,15 @@ type ChainConfig struct { AmsterdamTime *uint64 `json:"amsterdamTime,omitempty"` // Amsterdam switch time (nil = no fork, 0 = already on amsterdam) UBTTime *uint64 `json:"ubtTime,omitempty"` // UBT switch time (nil = no fork, 0 = already on UBT) + // UBTTransitionEndTime is the timestamp at which the MPT-to-binary + // transition tree is no longer applied. While UBT is active and the + // timestamp is below this value (or nil), state access is wrapped in a + // TransitionTrie that overlays the binary trie on the frozen MPT base. + // Once headers reach this time, the transition wrapper is dropped and + // state is read directly from the binary trie. nil = wrapper stays on + // indefinitely. Mirrors the threshold semantics of TerminalTotalDifficulty. + UBTTransitionEndTime *uint64 `json:"ubtTransitionEndTime,omitempty"` + // TerminalTotalDifficulty is the amount of total difficulty reached by // the network that triggers the consensus upgrade. TerminalTotalDifficulty *big.Int `json:"terminalTotalDifficulty,omitempty"` @@ -598,6 +607,9 @@ func (c *ChainConfig) String() string { if c.UBTTime != nil { result += fmt.Sprintf(", UBTTime: %v", *c.UBTTime) } + if c.UBTTransitionEndTime != nil { + result += fmt.Sprintf(", UBTTransitionEndTime: %v", *c.UBTTransitionEndTime) + } result += "}" return result } @@ -693,6 +705,9 @@ func (c *ChainConfig) Description() string { if c.UBTTime != nil { banner += fmt.Sprintf(" - UBT: @%-10v blob: (%s)\n", *c.UBTTime, c.BlobScheduleConfig.UBT) } + if c.UBTTransitionEndTime != nil { + banner += fmt.Sprintf(" - UBT transition tree ends: @%-10v\n", *c.UBTTransitionEndTime) + } banner += fmt.Sprintf("\nAll fork specifications can be found at https://ethereum.github.io/execution-specs/src/ethereum/forks/\n") return banner } @@ -871,6 +886,17 @@ func (c *ChainConfig) IsUBT(num *big.Int, time uint64) bool { return c.IsLondon(num) && isTimestampForked(c.UBTTime, time) } +// UBTTransitionActive reports whether state access at the given block number +// and time should still be wrapped in a TransitionTrie (binary overlay on top +// of the frozen MPT base). It is true when UBT is active and either no end +// time is configured or the block is still before the configured end time. +func (c *ChainConfig) UBTTransitionActive(num *big.Int, time uint64) bool { + if !c.IsUBT(num, time) { + return false + } + return c.UBTTransitionEndTime == nil || time < *c.UBTTransitionEndTime +} + // IsUBTGenesis checks whether the verkle fork is activated at the genesis block. // // Verkle mode is considered enabled if the verkle fork time is configured, diff --git a/params/protocol_params.go b/params/protocol_params.go index 9da275c486..831d05a3b9 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -222,6 +222,16 @@ var ( // EIP-7251 - Increase the MAX_EFFECTIVE_BALANCE ConsolidationQueueAddress = common.HexToAddress("0x0000BBdDc7CE488642fb579F8B00f3a590007251") ConsolidationQueueCode = common.FromHex("3373fffffffffffffffffffffffffffffffffffffffe1460d35760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461019a57600182026001905f5b5f82111560685781019083028483029004916001019190604d565b9093900492505050366060146088573661019a573461019a575f5260205ff35b341061019a57600154600101600155600354806004026004013381556001015f358155600101602035815560010160403590553360601b5f5260605f60143760745fa0600101600355005b6003546002548082038060021160e7575060025b5f5b8181146101295782810160040260040181607402815460601b815260140181600101548152602001816002015481526020019060030154905260010160e9565b910180921461013b5790600255610146565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561017357505f5b6001546001828201116101885750505f61018e565b01600190035b5f555f6001556074025ff35b5f5ffd") + + // BinaryTransitionRegistryAddress is the system contract that exposes the + // MPT-to-binary transition state via storage slots: + // slot 0 = transition started flag + // slot 1 = address of the last translated account + // slot 2 = hash of the last translated storage slot + // slot 3 = "storage processed" flag + // slot 4 = transition ended flag + // slot 5 = frozen MPT base root + BinaryTransitionRegistryAddress = common.HexToAddress("0x1622162216221622162216221622162216221622") ) // System log events. From 40c29ad53a196c2ce24a5747fda02ce4c15d9ee3 Mon Sep 17 00:00:00 2001 From: Guillaume Ballet Date: Wed, 29 Apr 2026 16:21:59 +0200 Subject: [PATCH 2/6] core/overlay: load transition state from system contract Replace the gob-encoded `rawdb.{Read,Write}VerkleTransitionState` plumbing with a direct read from the binary transition registry system contract at `params.BinaryTransitionRegistryAddress`. The registry exposes the transition state via fixed storage slots; a tiny `StorageReader` interface (`Storage(addr, slot) (Hash, error)`) captures what the loader needs. `LoadTransitionState` now takes a `StorageReader` instead of an `ethdb.KeyValueReader` and returns `nil` when the registry has not been initialised (slot 0 unset). `IsTransitionActive` is exposed for callers that only need the started flag. `core/state/reader.go:newUBTTrieReader` is updated: - It now takes the binary triedb, an optional MPT triedb, and a `wrapInTransitionTrie` flag so callers can opt out of the wrap. - It uses a small `binTrieStorageReader` adapter to query the registry directly from the binary trie at the requested root, avoiding the MPT key-hashing in `flatReader`. - When wrap=true and the registry's BaseRoot is non-zero, the MPT base is opened against the supplied MPT triedb. With the current callers (mptdb=nil) the wrap degenerates to a passthrough, preserving existing master semantics until the dual-triedb routing lands in the next commit. The dead `rawdb` accessors and `VerkleTransitionStatePrefix` schema constant are removed. --- core/overlay/state_transition.go | 128 ++++++++++++++++++------------- core/rawdb/accessors_overlay.go | 30 -------- core/rawdb/database.go | 2 +- core/rawdb/schema.go | 7 -- core/state/database_ubt.go | 6 +- core/state/reader.go | 73 ++++++++++-------- 6 files changed, 120 insertions(+), 126 deletions(-) delete mode 100644 core/rawdb/accessors_overlay.go diff --git a/core/overlay/state_transition.go b/core/overlay/state_transition.go index afd2bab017..356c77b794 100644 --- a/core/overlay/state_transition.go +++ b/core/overlay/state_transition.go @@ -17,29 +17,42 @@ package overlay import ( - "bytes" - "encoding/gob" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/rawdb" - "github.com/ethereum/go-ethereum/ethdb" - "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" ) -// TransitionState is a structure that holds the progress markers of the -// translation process. +// Storage slots used by the binary transition registry system contract at +// params.BinaryTransitionRegistryAddress. +var ( + transitionStartedKey = common.Hash{} + conversionProgressAddressKey = common.BytesToHash([]byte{1}) + conversionProgressSlotKey = common.BytesToHash([]byte{2}) + conversionProgressStorageProcessed = common.BytesToHash([]byte{3}) + transitionEndedKey = common.BytesToHash([]byte{4}) + baseRootKey = common.BytesToHash([]byte{5}) +) + +// StorageReader is a minimal interface for reading contract storage slots. +// It is satisfied by *state.flatReader, allowing the transition state to be +// loaded without a full state.StateDB. +type StorageReader interface { + Storage(addr common.Address, slot common.Hash) (common.Hash, error) +} + +// TransitionState holds the progress markers of the MPT-to-binary +// translation process. It is reconstructed on demand from the storage of the +// binary transition registry system contract. type TransitionState struct { - CurrentAccountAddress *common.Address // addresss of the last translated account + CurrentAccountAddress *common.Address // address of the last translated account CurrentSlotHash common.Hash // hash of the last translated storage slot - CurrentPreimageOffset int64 // next byte to read from the preimage file Started, Ended bool - // Mark whether the storage for an account has been processed. This is useful if the - // maximum number of leaves of the conversion is reached before the whole storage is - // processed. + // StorageProcessed marks whether the storage of the current account has + // been fully processed. Useful when the maximum number of leaves of the + // conversion is reached before the storage is exhausted. StorageProcessed bool - BaseRoot common.Hash // hash of the last read-only MPT base tree + BaseRoot common.Hash // frozen MPT base root captured at the fork block } // InTransition returns true if the translation process is in progress. @@ -55,12 +68,11 @@ func (ts *TransitionState) Transitioned() bool { // Copy returns a deep copy of the TransitionState object. func (ts *TransitionState) Copy() *TransitionState { ret := &TransitionState{ - Started: ts.Started, - Ended: ts.Ended, - CurrentSlotHash: ts.CurrentSlotHash, - CurrentPreimageOffset: ts.CurrentPreimageOffset, - StorageProcessed: ts.StorageProcessed, - BaseRoot: ts.BaseRoot, + Started: ts.Started, + Ended: ts.Ended, + CurrentSlotHash: ts.CurrentSlotHash, + StorageProcessed: ts.StorageProcessed, + BaseRoot: ts.BaseRoot, } if ts.CurrentAccountAddress != nil { addr := *ts.CurrentAccountAddress @@ -69,38 +81,48 @@ func (ts *TransitionState) Copy() *TransitionState { return ret } -// LoadTransitionState retrieves the Verkle transition state associated with -// the given state root hash from the database. -func LoadTransitionState(db ethdb.KeyValueReader, root common.Hash, isUBT bool) *TransitionState { - var ts *TransitionState - - data, _ := rawdb.ReadVerkleTransitionState(db, root) - - // if a state could be read from the db, attempt to decode it - if len(data) > 0 { - var ( - newts TransitionState - buf = bytes.NewBuffer(data[:]) - dec = gob.NewDecoder(buf) - ) - // Decode transition state - err := dec.Decode(&newts) - if err != nil { - log.Error("failed to decode transition state", "err", err) - return nil - } - ts = &newts +// IsTransitionActive checks whether the binary transition registry has been +// initialised by reading slot 0 (started) from the system contract. +func IsTransitionActive(reader StorageReader) bool { + val, err := reader.Storage(params.BinaryTransitionRegistryAddress, transitionStartedKey) + if err != nil { + return false + } + return val != (common.Hash{}) +} + +// LoadTransitionState reads the full transition state from the binary +// transition registry system contract storage. Returns nil when the +// registry has not been initialised (i.e. the chain has not yet reached the +// UBT fork block). +// +// The root parameter is unused; it is retained on the signature so callers +// can express the state version they intend to read. +func LoadTransitionState(reader StorageReader, root common.Hash) *TransitionState { + started, err := reader.Storage(params.BinaryTransitionRegistryAddress, transitionStartedKey) + if err != nil || started == (common.Hash{}) { + return nil + } + + ended, _ := reader.Storage(params.BinaryTransitionRegistryAddress, transitionEndedKey) + baseRoot, _ := reader.Storage(params.BinaryTransitionRegistryAddress, baseRootKey) + + var currentAddr *common.Address + addrVal, _ := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressAddressKey) + if addrVal != (common.Hash{}) { + addr := common.BytesToAddress(addrVal.Bytes()) + currentAddr = &addr + } + + slotHash, _ := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressSlotKey) + storageProcessed, _ := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressStorageProcessed) + + return &TransitionState{ + Started: true, + Ended: ended != (common.Hash{}), + BaseRoot: baseRoot, + CurrentAccountAddress: currentAddr, + CurrentSlotHash: slotHash, + StorageProcessed: storageProcessed != (common.Hash{}), } - - // Fallback that should only happen before the transition - if ts == nil { - // Initialize the first transition state, with the "ended" - // field set to true if the database was created - // as a verkle database. - log.Debug("no transition state found, starting fresh", "verkle", isUBT) - - // Start with a fresh state - ts = &TransitionState{Ended: isUBT} - } - return ts } diff --git a/core/rawdb/accessors_overlay.go b/core/rawdb/accessors_overlay.go deleted file mode 100644 index 364cc889d1..0000000000 --- a/core/rawdb/accessors_overlay.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2025 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -package rawdb - -import ( - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethdb" -) - -func ReadVerkleTransitionState(db ethdb.KeyValueReader, hash common.Hash) ([]byte, error) { - return db.Get(transitionStateKey(hash)) -} - -func WriteVerkleTransitionState(db ethdb.KeyValueWriter, hash common.Hash, state []byte) error { - return db.Put(transitionStateKey(hash), state) -} diff --git a/core/rawdb/database.go b/core/rawdb/database.go index 39e1a64e5a..ccf2eace65 100644 --- a/core/rawdb/database.go +++ b/core/rawdb/database.go @@ -691,7 +691,7 @@ var knownMetadataKeys = [][]byte{ snapshotGeneratorKey, snapshotRecoveryKey, txIndexTailKey, fastTxLookupLimitKey, uncleanShutdownKey, badBlockKey, transitionStatusKey, skeletonSyncStatusKey, persistentStateIDKey, trieJournalKey, snapshotSyncStatusKey, snapSyncStatusFlagKey, - filterMapsRangeKey, headStateHistoryIndexKey, headTrienodeHistoryIndexKey, VerkleTransitionStatePrefix, + filterMapsRangeKey, headStateHistoryIndexKey, headTrienodeHistoryIndexKey, } // printChainMetadata prints out chain metadata to stderr. diff --git a/core/rawdb/schema.go b/core/rawdb/schema.go index 54c76143b4..d3f6944316 100644 --- a/core/rawdb/schema.go +++ b/core/rawdb/schema.go @@ -165,9 +165,6 @@ var ( preimageCounter = metrics.NewRegisteredCounter("db/preimage/total", nil) preimageHitsCounter = metrics.NewRegisteredCounter("db/preimage/hits", nil) preimageMissCounter = metrics.NewRegisteredCounter("db/preimage/miss", nil) - - // Verkle transition information - VerkleTransitionStatePrefix = []byte("verkle-transition-state-") ) // LegacyTxLookupEntry is the legacy TxLookupEntry definition with some unnecessary @@ -461,7 +458,3 @@ func trienodeHistoryIndexBlockKey(addressHash common.Hash, path []byte, blockID return out } -// transitionStateKey = transitionStatusKey + hash -func transitionStateKey(hash common.Hash) []byte { - return append(VerkleTransitionStatePrefix, hash.Bytes()...) -} diff --git a/core/state/database_ubt.go b/core/state/database_ubt.go index 718d93df87..66653f6112 100644 --- a/core/state/database_ubt.go +++ b/core/state/database_ubt.go @@ -60,8 +60,10 @@ func (db *UBTDatabase) StateReader(stateRoot common.Hash) (StateReader, error) { } } // Configure the trie reader, which is expected to be available as the - // gatekeeper unless the state is corrupted. - tr, err := newUBTTrieReader(stateRoot, db.triedb) + // gatekeeper unless the state is corrupted. The transition tree wrap is + // kept on by default so the registry's BaseRoot (when populated) is + // honoured; the MPT triedb is plumbed through in a later commit. + tr, err := newUBTTrieReader(stateRoot, db.triedb, nil, true) if err != nil { return nil, err } diff --git a/core/state/reader.go b/core/state/reader.go index 5df0acbb9b..7b8ff35ac1 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -252,46 +252,53 @@ type ubtTrieReader struct { lock sync.Mutex // Lock for protecting concurrent read } +// binTrieStorageReader adapts a *bintrie.BinaryTrie to overlay.StorageReader +// so the transition state can be loaded directly from the binary trie at a +// specific state root. +type binTrieStorageReader struct { + tr *bintrie.BinaryTrie +} + +func (r *binTrieStorageReader) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { + val, err := r.tr.GetStorage(addr, slot.Bytes()) + if err != nil { + return common.Hash{}, err + } + var out common.Hash + out.SetBytes(val) + return out, nil +} + // newUBTTrieReader constructs a Unified-binary-trie reader of the specific state. // An error will be returned if the associated trie specified by root is not existent. -func newUBTTrieReader(root common.Hash, db *triedb.Database) (*ubtTrieReader, error) { - binTrie, binErr := bintrie.NewBinaryTrie(root, db) - if binErr != nil { - return nil, binErr +// +// When wrapInTransitionTrie is true, reads fall through to the frozen MPT +// base whenever the transition registry has captured a non-zero base root +// in slot 5 and an MPT triedb is available. When wrapInTransitionTrie is +// false, the MPT base is never consulted regardless of registry state. +// +// Both branches still build a TransitionTrie because *bintrie.BinaryTrie +// cannot satisfy the state.Trie interface directly (import cycle between +// trie and trie/bintrie). When wrap is false the base argument is nil and +// the wrapper degenerates to a passthrough. +func newUBTTrieReader(root common.Hash, bindb *triedb.Database, mptdb *triedb.Database, wrapInTransitionTrie bool) (*ubtTrieReader, error) { + binTrie, err := bintrie.NewBinaryTrie(root, bindb) + if err != nil { + return nil, err } - // Based on the transition status, determine if the overlay - // tree needs to be created, or if a single, target tree is - // to be picked. - var ( - tr Trie - ts = overlay.LoadTransitionState(db.Disk(), root, true) - ) - if ts.InTransition() { - mpt, err := trie.NewStateTrie(trie.StateTrieID(ts.BaseRoot), db) - if err != nil { - return nil, err + var base *trie.StateTrie + if wrapInTransitionTrie && mptdb != nil { + if ts := overlay.LoadTransitionState(&binTrieStorageReader{tr: binTrie}, root); ts != nil && ts.BaseRoot != (common.Hash{}) { + base, err = trie.NewStateTrie(trie.StateTrieID(ts.BaseRoot), mptdb) + if err != nil { + return nil, err + } } - tr = transitiontrie.NewTransitionTrie(mpt, binTrie, false) - } else { - // HACK: Use TransitionTrie with nil base as a wrapper to make BinaryTrie - // satisfy the Trie interface. This works around the import cycle between - // trie and trie/bintrie packages. - // - // TODO: In future PRs, refactor the package structure to avoid this hack: - // - Option 1: Move common interfaces (Trie, NodeIterator) to a separate - // package that both trie and trie/bintrie can import - // - Option 2: Create a factory function in the trie package that returns - // BinaryTrie as a Trie interface without direct import - // - Option 3: Move BinaryTrie to the main trie package - // - // The current approach works but adds unnecessary overhead and complexity - // by using TransitionTrie when there's no actual transition happening. - tr = transitiontrie.NewTransitionTrie(nil, binTrie, false) } return &ubtTrieReader{ root: root, - db: db, - tr: tr, + db: bindb, + tr: transitiontrie.NewTransitionTrie(base, binTrie, false), }, nil } From 58518ea2b3b8b31fe602a60bdfd94d8cabe68109 Mon Sep 17 00:00:00 2001 From: Guillaume Ballet Date: Wed, 29 Apr 2026 16:46:48 +0200 Subject: [PATCH 3/6] core, miner: bootstrap the binary transition registry on the first UBT block Add `core/transition_registry.go` with two helpers: - `InitializeBinaryTransitionRegistry` deploys the system contract at `params.BinaryTransitionRegistryAddress`. The contract's bytecode returns the storage slot indexed by the call's CALLDATA, exposing the transition state to off-chain readers. - `WriteBinaryTransitionBaseRoot` writes the frozen MPT base root into slot 5. The slot constant is kept private so callers go through this helper. Wire both calls into the three places that build state for a new block: - `core/state_processor.go`: in `Process`, after the EIP-2935 system call, when the current block is on UBT and the parent is not. - `miner/worker.go`: at the end of `prepareWork`, with the same fork-boundary check, so locally-built payloads also seed the registry. - `core/chain_makers.go`: in `GenerateChain`, between the EIP-2935 handling and the user-supplied `gen` callback, so generated test chains see the registry deployed identically to a live chain. --- core/chain_makers.go | 7 +++++ core/state_processor.go | 11 +++++++ core/transition_registry.go | 62 +++++++++++++++++++++++++++++++++++++ miner/worker.go | 7 +++++ 4 files changed, 87 insertions(+) create mode 100644 core/transition_registry.go diff --git a/core/chain_makers.go b/core/chain_makers.go index 46cd98de61..b45f3f03af 100644 --- a/core/chain_makers.go +++ b/core/chain_makers.go @@ -399,6 +399,13 @@ func GenerateChain(config *params.ChainConfig, parent *types.Block, engine conse ProcessParentBlockHash(b.header.ParentHash, evm) } + // On the first UBT block, deploy the binary transition registry and + // record the parent's MPT root as the frozen base root. + if config.IsUBT(b.header.Number, b.header.Time) && !config.IsUBT(parent.Number(), parent.Time()) { + InitializeBinaryTransitionRegistry(statedb) + WriteBinaryTransitionBaseRoot(statedb, parent.Root()) + } + // Execute any user modifications to the block if gen != nil { gen(i, b) diff --git a/core/state_processor.go b/core/state_processor.go index fda3bf8fe7..71d9a599a2 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -95,6 +95,17 @@ func (p *StateProcessor) Process(ctx context.Context, block *types.Block, stated if config.IsPrague(block.Number(), block.Time()) || config.IsUBT(block.Number(), block.Time()) { ProcessParentBlockHash(block.ParentHash(), evm) } + // On the first block after the UBT activation, deploy the binary + // transition registry system contract and capture the frozen MPT base + // root in slot 5. The registry is what every subsequent block reads to + // reconstruct the transition state. + if config.IsUBT(block.Number(), block.Time()) { + parent := p.chain.GetHeaderByHash(block.ParentHash()) + if parent != nil && !config.IsUBT(parent.Number, parent.Time) { + InitializeBinaryTransitionRegistry(statedb) + WriteBinaryTransitionBaseRoot(statedb, parent.Root) + } + } // Iterate over and process the individual transactions for i, tx := range block.Transactions() { diff --git a/core/transition_registry.go b/core/transition_registry.go new file mode 100644 index 0000000000..5dff348f00 --- /dev/null +++ b/core/transition_registry.go @@ -0,0 +1,62 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package core + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/params" +) + +// transitionStatusByteCode is a minimal contract that returns a single 32-byte +// storage slot to the caller. CALLDATALOAD picks the slot index from the call +// input and SLOAD reads the value, which is then returned as a 32-byte word. +var transitionStatusByteCode = []byte{ + 0x60, 0x00, // PUSH1 0 + 0x35, // CALLDATALOAD (slot index) + 0x54, // SLOAD + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN +} + +// transitionRegistryBaseRootSlot is slot 5 of the transition registry, where +// the frozen MPT base root is stored. The slot indices match those decoded by +// overlay.LoadTransitionState; the layout is intentionally kept private to +// the core package so external callers go through these helpers. +var transitionRegistryBaseRootSlot = common.BytesToHash([]byte{5}) + +// InitializeBinaryTransitionRegistry deploys the binary transition registry +// system contract and marks the transition as started by writing 1 into slot +// 0. It must be called exactly once, on the first block after the UBT +// activation. +func InitializeBinaryTransitionRegistry(statedb *state.StateDB) { + statedb.SetCode(params.BinaryTransitionRegistryAddress, transitionStatusByteCode, tracing.CodeChangeUnspecified) + statedb.SetNonce(params.BinaryTransitionRegistryAddress, 1, tracing.NonceChangeUnspecified) + statedb.SetState(params.BinaryTransitionRegistryAddress, common.Hash{}, common.Hash{1}) +} + +// WriteBinaryTransitionBaseRoot records the frozen MPT base root in slot 5 of +// the transition registry. This must be called on the first UBT block, right +// after InitializeBinaryTransitionRegistry, with the parent block's state +// root. +func WriteBinaryTransitionBaseRoot(statedb *state.StateDB, baseRoot common.Hash) { + statedb.SetState(params.BinaryTransitionRegistryAddress, transitionRegistryBaseRootSlot, baseRoot) +} diff --git a/miner/worker.go b/miner/worker.go index ae5d6c306f..4598083151 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -332,6 +332,13 @@ func (miner *Miner) prepareWork(ctx context.Context, genParams *generateParams, if miner.chainConfig.IsPrague(header.Number, header.Time) { core.ProcessParentBlockHash(header.ParentHash, env.evm) } + // Deploy the binary transition registry on the first UBT block and seed + // it with the parent's MPT root. Subsequent blocks read the registry to + // reconstruct the transition state. + if miner.chainConfig.IsUBT(header.Number, header.Time) && !miner.chainConfig.IsUBT(parent.Number, parent.Time) { + core.InitializeBinaryTransitionRegistry(env.state) + core.WriteBinaryTransitionBaseRoot(env.state, parent.Root) + } return env, nil } From d8ed290a40180cd6e61419e9e59d5bf7faa65a34 Mon Sep 17 00:00:00 2001 From: Guillaume Ballet Date: Wed, 29 Apr 2026 16:55:13 +0200 Subject: [PATCH 4/6] core, core/state: dual-triedb routing for the MPT-to-UBT transition This is the wiring that makes the binary transition registry actually useful. The chain now keeps two trie databases in parallel during the fork window: - bc.triedb stays in MPT mode and holds pre-fork state. - bc.bintriedb is a sibling triedb in UBT mode, allocated when UBTTime is set and the chain did not start binary at genesis. Routing happens through the new bc.stateDatabase(parentRoot, header): - pre-UBT block: MPTDatabase backed by bc.triedb. - post-UBTTransitionEndTime block: plain UBTDatabase with the transition tree wrap explicitly disabled. - first UBT block (parent is pre-UBT): a transition UBTDatabase seeded with the parent root as the frozen MPT base. - subsequent UBT block while transitioning: probeTransitionDatabase inspects the registry and chooses between the transition variant (when slot 5 still records a non-zero base root) and the plain variant otherwise. UBTDatabase grows the supporting fields: - mpttriedb + baseRoot for the transition window. - NewTransitionUBTDatabase constructor. - WithTransitionTreeWrap(bool) toggle for the override path. - OpenTrie / OpenStorageTrie now serve TransitionTrie / MPT storage tries while the transition is live. - Commit substitutes EmptyBinaryHash for the originRoot on the very first transition block, so the binary triedb does not reject the MPT-rooted parent. StateAt / StateAtForkBoundary on BlockChain now defer to stateDatabase, so the miner, ProcessBlock and historical state lookups all share one routing path. bc.bintriedb is journaled and closed alongside bc.triedb. Adds core/chain_makers.go BlockGen.GetState so tests can inspect storage slots written during block production, and a new core/bintrie_transition_test.go that walks a chain across the fork and asserts the registry's started flag and base root are populated exactly when expected. --- core/bintrie_transition_test.go | 107 ++++++++++++++++++++++++++++++++ core/blockchain.go | 106 ++++++++++++++++++++++++++++--- core/blockchain_reader.go | 18 +----- core/chain_makers.go | 8 +++ core/state/database_ubt.go | 102 +++++++++++++++++++++++++----- 5 files changed, 300 insertions(+), 41 deletions(-) create mode 100644 core/bintrie_transition_test.go diff --git a/core/bintrie_transition_test.go b/core/bintrie_transition_test.go new file mode 100644 index 0000000000..4c3aff90f8 --- /dev/null +++ b/core/bintrie_transition_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package core + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/beacon" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/params" +) + +// TestBinaryTransitionRegistryBootstrap exercises the registry deployment on +// the first UBT block: pre-fork blocks must leave the registry uninitialised, +// the first UBT block must mark started=true and the base root must be +// captured for every subsequent UBT block. +func TestBinaryTransitionRegistryBootstrap(t *testing.T) { + var ( + ubtTime uint64 = 30 + coinbase = common.HexToAddress("0x71562b71999873DB5b286dF957af199Ec94617F7") + gspec = &Genesis{ + Config: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + HomesteadBlock: big.NewInt(0), + EIP150Block: big.NewInt(0), + EIP155Block: big.NewInt(0), + EIP158Block: big.NewInt(0), + ByzantiumBlock: big.NewInt(0), + ConstantinopleBlock: big.NewInt(0), + PetersburgBlock: big.NewInt(0), + IstanbulBlock: big.NewInt(0), + MuirGlacierBlock: big.NewInt(0), + BerlinBlock: big.NewInt(0), + LondonBlock: big.NewInt(0), + Ethash: new(params.EthashConfig), + ShanghaiTime: u64(0), + CancunTime: u64(0), + PragueTime: u64(0), + UBTTime: &ubtTime, + TerminalTotalDifficulty: common.Big0, + EnableUBTAtGenesis: false, + BlobScheduleConfig: ¶ms.BlobScheduleConfig{ + Cancun: params.DefaultCancunBlobConfig, + Prague: params.DefaultPragueBlobConfig, + UBT: params.DefaultPragueBlobConfig, + }, + }, + Alloc: GenesisAlloc{ + coinbase: { + Balance: big.NewInt(1000000000000000000), + }, + params.BeaconRootsAddress: {Nonce: 1, Code: params.BeaconRootsCode, Balance: common.Big0}, + params.HistoryStorageAddress: {Nonce: 1, Code: params.HistoryStorageCode, Balance: common.Big0}, + params.WithdrawalQueueAddress: {Nonce: 1, Code: params.WithdrawalQueueCode, Balance: common.Big0}, + params.ConsolidationQueueAddress: {Nonce: 1, Code: params.ConsolidationQueueCode, Balance: common.Big0}, + }, + } + ) + config := gspec.Config + engine := beacon.New(ethash.NewFaker()) + + registryAddr := params.BinaryTransitionRegistryAddress + slotStarted := common.Hash{} + slotBaseRoot := common.BytesToHash([]byte{5}) + + GenerateChainWithGenesis(gspec, engine, 6, func(i int, gen *BlockGen) { + gen.SetPoS() + + blockNum := gen.Number() + blockTime := gen.Timestamp() + isUBT := config.IsUBT(blockNum, blockTime) + + started := gen.GetState(registryAddr, slotStarted) + baseRoot := gen.GetState(registryAddr, slotBaseRoot) + t.Logf("block %d: num=%d time=%d isUBT=%v started=%x baseRoot=%x", + i, blockNum.Uint64(), blockTime, isUBT, started, baseRoot) + + if !isUBT { + if started != (common.Hash{}) { + t.Errorf("block %d: pre-transition block should not have registry initialized", i) + } + return + } + if started == (common.Hash{}) { + t.Errorf("block %d: UBT block should have registry slot 0 (started) set", i) + } + if baseRoot == (common.Hash{}) { + t.Errorf("block %d: UBT block should have registry slot 5 (base root) set", i) + } + }) +} diff --git a/core/blockchain.go b/core/blockchain.go index 296ef6bc16..ce3efe9426 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -38,6 +38,7 @@ import ( "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/consensus/misc/eip4844" "github.com/ethereum/go-ethereum/core/history" + "github.com/ethereum/go-ethereum/core/overlay" "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/state/snapshot" @@ -257,6 +258,13 @@ func (cfg BlockChainConfig) WithNoAsyncFlush(on bool) *BlockChainConfig { return &cfg } +// newBinaryTrieDB opens the sibling triedb that holds binary trie nodes +// during the MPT-to-binary transition. It uses the same scheme and limits as +// the main triedb but is forced into UBT mode. +func newBinaryTrieDB(db ethdb.Database, cfg *BlockChainConfig) *triedb.Database { + return triedb.NewDatabase(db, cfg.triedbConfig(true)) +} + // triedbConfig derives the configures for trie database. func (cfg *BlockChainConfig) triedbConfig(isUBT bool) *triedb.Config { config := &triedb.Config{ @@ -323,6 +331,7 @@ type BlockChain struct { lastWrite uint64 // Last block when the state was flushed flushInterval atomic.Int64 // Time interval (processing time) after which to flush a state triedb *triedb.Database // The database handler for maintaining trie nodes. + bintriedb *triedb.Database // Sibling triedb for binary trie nodes during the MPT-to-binary transition; nil if no UBT fork is configured or the chain is binary at genesis. codedb *state.CodeDB // The database handler for maintaining contract codes. txIndexer *txIndexer // Transaction indexer, might be nil if not enabled @@ -382,16 +391,26 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine, if err != nil { return nil, err } - triedb := triedb.NewDatabase(db, cfg.triedbConfig(enableVerkle)) + mainTriedb := triedb.NewDatabase(db, cfg.triedbConfig(enableVerkle)) + + // Open a sibling binary trie database when the chain is configured for an + // MPT-to-binary transition (i.e. UBTTime is set and the chain did not start + // binary at genesis). The two databases run in parallel during the + // transition window: writes target the binary trie, reads fall through to + // the frozen MPT base resolved from the transition registry. + var bintriedb *triedb.Database // Write the supplied genesis to the database if it has not been initialized // yet. The corresponding chain config will be returned, either from the // provided genesis or from the locally stored configuration if the genesis // has already been initialized. - chainConfig, genesisHash, compatErr, err := SetupGenesisBlockWithOverride(db, triedb, genesis, cfg.Overrides, cfg.VmConfig.Tracer) + chainConfig, genesisHash, compatErr, err := SetupGenesisBlockWithOverride(db, mainTriedb, genesis, cfg.Overrides, cfg.VmConfig.Tracer) if err != nil { return nil, err } + if !enableVerkle && chainConfig.UBTTime != nil { + bintriedb = newBinaryTrieDB(db, cfg) + } log.Info("") log.Info(strings.Repeat("-", 153)) for _, line := range strings.Split(chainConfig.Description(), "\n") { @@ -404,7 +423,8 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine, chainConfig: chainConfig, cfg: cfg, db: db, - triedb: triedb, + triedb: mainTriedb, + bintriedb: bintriedb, codedb: state.NewCodeDB(db), triegc: prque.New[int64, common.Hash](nil), chainmu: syncx.NewClosableMutex(), @@ -1349,6 +1369,11 @@ func (bc *BlockChain) Stop() { if err := bc.triedb.Journal(bc.CurrentBlock().Root); err != nil { log.Info("Failed to journal in-memory trie nodes", "err", err) } + if bc.bintriedb != nil { + if err := bc.bintriedb.Journal(bc.CurrentBlock().Root); err != nil { + log.Info("Failed to journal in-memory binary trie nodes", "err", err) + } + } } else { // Ensure the state of a recent block is also stored to disk before exiting. // We're writing three different states to catch different restart scenarios: @@ -1390,6 +1415,11 @@ func (bc *BlockChain) Stop() { if err := bc.triedb.Close(); err != nil { log.Error("Failed to close trie database", "err", err) } + if bc.bintriedb != nil { + if err := bc.bintriedb.Close(); err != nil { + log.Error("Failed to close binary trie database", "err", err) + } + } log.Info("Blockchain stopped") } @@ -2108,6 +2138,69 @@ type ExecuteConfig struct { EnableWitnessStats bool } +// stateDatabase returns the appropriate state.Database for executing a block +// with the given header against the supplied parent root. The routing is: +// +// - pre-UBT-fork blocks → MPTDatabase backed by bc.triedb. +// - first UBT block (parent is pre-UBT) → transition UBTDatabase, with the +// binary trie as primary store and the parent's MPT root as the frozen +// base. The MPT triedb is provided for read-through during the transition. +// - subsequent UBT blocks → if the registry still reports an active +// transition, a transition UBTDatabase is built; otherwise (transition +// ended, or chainConfig.UBTTransitionEndTime crossed) a plain UBTDatabase +// is returned with the transition wrap disabled. +func (bc *BlockChain) stateDatabase(parentRoot common.Hash, header *types.Header) state.Database { + if !bc.chainConfig.IsUBT(header.Number, header.Time) { + return state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) + } + // Past the configured transition end: pure binary, no wrap, no MPT base. + if !bc.chainConfig.UBTTransitionActive(header.Number, header.Time) { + return state.NewUBTDatabase(bc.bintriedbOrMain(), bc.codedb).WithTransitionTreeWrap(false) + } + // First UBT block: parent is pre-UBT; seed the transition with parent root. + parent := bc.GetHeaderByHash(header.ParentHash) + if parent != nil && !bc.chainConfig.IsUBT(parent.Number, parent.Time) { + return state.NewTransitionUBTDatabase(bc.bintriedbOrMain(), bc.triedb, bc.codedb, parentRoot) + } + // Subsequent UBT block while the transition is active: probe the registry + // to see whether a base root is still recorded. + return bc.probeTransitionDatabase(parentRoot) +} + +// bintriedbOrMain returns bc.bintriedb when present, otherwise the main triedb. +// Binary-at-genesis chains have no sibling binary triedb because the main +// triedb is already in UBT mode. +func (bc *BlockChain) bintriedbOrMain() *triedb.Database { + if bc.bintriedb != nil { + return bc.bintriedb + } + return bc.triedb +} + +// probeTransitionDatabase reads the transition registry from the binary trie +// at the given root and chooses between a transition-mode UBTDatabase (if +// the registry still records a base root) and a plain UBTDatabase otherwise. +func (bc *BlockChain) probeTransitionDatabase(root common.Hash) state.Database { + bindb := bc.bintriedbOrMain() + plain := state.NewUBTDatabase(bindb, bc.codedb) + reader, err := plain.StateReader(root) + if err != nil { + return plain + } + ts := overlay.LoadTransitionState(storageReaderFunc(reader.Storage), root) + if ts == nil || ts.Transitioned() || ts.BaseRoot == (common.Hash{}) { + return plain + } + return state.NewTransitionUBTDatabase(bindb, bc.triedb, bc.codedb, ts.BaseRoot) +} + +// storageReaderFunc adapts a state-reader Storage method to overlay.StorageReader. +type storageReaderFunc func(addr common.Address, slot common.Hash) (common.Hash, error) + +func (f storageReaderFunc) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { + return f(addr, slot) +} + // ProcessBlock executes and validates the given block. If there was no error // it writes the block and associated state to database. func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, block *types.Block, config ExecuteConfig) (result *blockProcessingResult, blockEndErr error) { @@ -2116,15 +2209,10 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, startTime = time.Now() statedb *state.StateDB interrupt atomic.Bool - sdb state.Database ) defer interrupt.Store(true) // terminate the prefetch at the end - if bc.chainConfig.IsUBT(block.Number(), block.Time()) { - sdb = state.NewUBTDatabase(bc.triedb, bc.codedb) - } else { - sdb = state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) - } + sdb := bc.stateDatabase(parentRoot, block.Header()) // If prefetching is enabled, run that against the current state to pre-cache // transactions and probabilistically some of the account/storage trie nodes. // diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go index 18afa9ce9d..4dd04b24c4 100644 --- a/core/blockchain_reader.go +++ b/core/blockchain_reader.go @@ -421,27 +421,13 @@ func (bc *BlockChain) State() (*state.StateDB, error) { // StateAt returns a new mutable state based on a particular point in time. func (bc *BlockChain) StateAt(header *types.Header) (*state.StateDB, error) { - if bc.chainConfig.IsUBT(header.Number, header.Time) { - return state.New(header.Root, state.NewUBTDatabase(bc.triedb, bc.codedb)) - } - return state.New(header.Root, state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps)) + return state.New(header.Root, bc.stateDatabase(header.Root, header)) } // StateAtForkBoundary returns a new mutable state based on the parent state // and the given header, handling the transition across the UBT fork. func (bc *BlockChain) StateAtForkBoundary(parent *types.Header, header *types.Header) (*state.StateDB, error) { - // The parent is already in the UBT fork. - if bc.chainConfig.IsUBT(parent.Number, parent.Time) { - return state.New(parent.Root, state.NewUBTDatabase(bc.triedb, bc.codedb)) - } - // The current block is the first block in the UBT fork - // (i.e., the parent is the last MPT block). - if bc.chainConfig.IsUBT(header.Number, header.Time) { - // TODO(gballet): register chain context if needed - return state.New(parent.Root, state.NewUBTDatabase(bc.triedb, bc.codedb)) - } - // Both the parent and current block are in the MPT fork. - return state.New(parent.Root, state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps)) + return state.New(parent.Root, bc.stateDatabase(parent.Root, header)) } // HistoricState returns a historic state specified by the given header. diff --git a/core/chain_makers.go b/core/chain_makers.go index b45f3f03af..aefae433ca 100644 --- a/core/chain_makers.go +++ b/core/chain_makers.go @@ -172,6 +172,14 @@ func (b *BlockGen) GetBalance(addr common.Address) *uint256.Int { return b.statedb.GetBalance(addr) } +// GetState returns the storage slot value for the given address and key as +// observed by the in-progress block generator. Useful in tests that need to +// inspect system-contract slots written during block production (e.g. the +// binary transition registry). +func (b *BlockGen) GetState(addr common.Address, key common.Hash) common.Hash { + return b.statedb.GetState(addr, key) +} + // AddUncheckedTx forcefully adds a transaction to the block without any validation. // // AddUncheckedTx will cause consensus failures when used during real diff --git a/core/state/database_ubt.go b/core/state/database_ubt.go index 66653f6112..437e2fdaf9 100644 --- a/core/state/database_ubt.go +++ b/core/state/database_ubt.go @@ -17,34 +17,78 @@ package state import ( + "errors" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/bintrie" + "github.com/ethereum/go-ethereum/trie/transitiontrie" "github.com/ethereum/go-ethereum/triedb" ) // UBTDatabase is an implementation of Database interface for Unified Binary Trie. -// It provides the same functionality as MPTDatabase but uses unified binary -// trie for state hashing instead of Merkle Patricia Tries. +// +// In its plain form it uses a single binary trie database. During the +// MPT-to-binary transition, an optional MPT trie database (mpttriedb) and a +// frozen base root provide read-only access to pre-transition state. The +// wrapInTransitionTrie flag controls whether reads at this database are +// served via a TransitionTrie that overlays the binary trie on the MPT +// base; it is normally driven by chainConfig.UBTTransitionActive. type UBTDatabase struct { - triedb *triedb.Database - codedb *CodeDB + triedb *triedb.Database + mpttriedb *triedb.Database + codedb *CodeDB + baseRoot common.Hash + wrapInTransitionTrie bool } // Type returns Binary, indicating this database is backed by a Universal Binary Trie. func (db *UBTDatabase) Type() DatabaseType { return TypeUBT } // NewUBTDatabase creates a state database with the Unified binary trie manner. +// State access is wrapped in a TransitionTrie by default (which degenerates +// to a passthrough when there is no MPT base) so callers that don't care +// about the override get sensible defaults. func NewUBTDatabase(triedb *triedb.Database, codedb *CodeDB) *UBTDatabase { if codedb == nil { codedb = NewCodeDB(triedb.Disk()) } return &UBTDatabase{ - triedb: triedb, - codedb: codedb, + triedb: triedb, + codedb: codedb, + wrapInTransitionTrie: true, } } +// NewTransitionUBTDatabase creates a UBTDatabase for the active MPT-to-binary +// transition window. The binary trie is the primary store; reads fall through +// to the frozen MPT at baseRoot via the supplied mpttriedb, and writes go +// only to the binary trie. +func NewTransitionUBTDatabase(bintriedb, mpttriedb *triedb.Database, codedb *CodeDB, baseRoot common.Hash) *UBTDatabase { + if codedb == nil { + codedb = NewCodeDB(bintriedb.Disk()) + } + return &UBTDatabase{ + triedb: bintriedb, + mpttriedb: mpttriedb, + codedb: codedb, + baseRoot: baseRoot, + wrapInTransitionTrie: true, + } +} + +// WithTransitionTreeWrap toggles whether reads at this database are wrapped +// in a TransitionTrie. Setting it to false disables the wrap regardless of +// the registry state and is intended for callers that have already crossed +// the configured UBTTransitionEndTime. +func (db *UBTDatabase) WithTransitionTreeWrap(wrap bool) *UBTDatabase { + db.wrapInTransitionTrie = wrap + return db +} + // StateReader returns a state reader associated with the specified state root. func (db *UBTDatabase) StateReader(stateRoot common.Hash) (StateReader, error) { var readers []StateReader @@ -60,10 +104,8 @@ func (db *UBTDatabase) StateReader(stateRoot common.Hash) (StateReader, error) { } } // Configure the trie reader, which is expected to be available as the - // gatekeeper unless the state is corrupted. The transition tree wrap is - // kept on by default so the registry's BaseRoot (when populated) is - // honoured; the MPT triedb is plumbed through in a later commit. - tr, err := newUBTTrieReader(stateRoot, db.triedb, nil, true) + // gatekeeper unless the state is corrupted. + tr, err := newUBTTrieReader(stateRoot, db.triedb, db.mpttriedb, db.wrapInTransitionTrie) if err != nil { return nil, err } @@ -96,15 +138,36 @@ func (db *UBTDatabase) ReadersWithCacheStats(stateRoot common.Hash) (Reader, Rea return ra, rb, nil } -// OpenTrie opens the main account trie at a specific root hash. +// OpenTrie opens the main account trie at a specific root hash. During an +// active transition, the binary trie is wrapped in a TransitionTrie so writes +// land on the binary trie while reads fall through to the frozen MPT base. func (db *UBTDatabase) OpenTrie(root common.Hash) (Trie, error) { - return bintrie.NewBinaryTrie(root, db.triedb) + bt, err := bintrie.NewBinaryTrie(root, db.triedb) + if err != nil { + return nil, err + } + if db.mpttriedb == nil || db.baseRoot == (common.Hash{}) { + return transitiontrie.NewTransitionTrie(nil, bt, false), nil + } + base, err := trie.NewStateTrie(trie.StateTrieID(db.baseRoot), db.mpttriedb) + if err != nil { + return nil, err + } + return transitiontrie.NewTransitionTrie(base, bt, false), nil } -// OpenStorageTrie opens the storage trie of an account. In binary trie mode, -// all state objects share one unified trie, so the main trie is returned. +// OpenStorageTrie opens the storage trie of an account. In binary trie mode +// the unified trie carries all state, so the main trie is reused. During the +// transition, an MPT storage trie is opened for accounts that have not yet +// been migrated. func (db *UBTDatabase) OpenStorageTrie(stateRoot common.Hash, address common.Address, root common.Hash, self Trie) (Trie, error) { - return self, nil + if self != nil && self.IsUBT() { + return self, nil + } + if db.mpttriedb == nil { + return nil, errors.New("no MPT trie database available for storage trie outside the transition window") + } + return trie.NewStateTrie(trie.StorageTrieID(stateRoot, crypto.Keccak256Hash(address.Bytes()), root), db.mpttriedb) } // TrieDB retrieves any intermediate trie-node caching layer. @@ -130,10 +193,17 @@ func (db *UBTDatabase) Commit(update *StateUpdate) error { return err } } + // On the first transition block, the originRoot is the MPT base root, + // but the binary trie's parent state is empty. Substitute the empty + // binary hash so triedb.Update doesn't reject the mismatch. + originRoot := update.OriginRoot + if db.mpttriedb != nil && originRoot == db.baseRoot { + originRoot = types.EmptyBinaryHash + } // Encode the state mutations in the UBT format accounts, accountOrigin, storages, storageOrigin := update.EncodeUBTState() - return db.triedb.Update(update.Root, update.OriginRoot, update.BlockNumber, update.Nodes, &triedb.StateSet{ + return db.triedb.Update(update.Root, originRoot, update.BlockNumber, update.Nodes, &triedb.StateSet{ Accounts: accounts, AccountsOrigin: accountOrigin, Storages: storages, From 9a5140a5902dc9704411102f1e15df6d4a7feb7c Mon Sep 17 00:00:00 2001 From: Guillaume Ballet <3272758+gballet@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:31:38 +0200 Subject: [PATCH 5/6] self-review --- core/blockchain.go | 26 ++++++++++++++++---------- core/blockchain_reader.go | 12 ++++++++++-- core/overlay/state_transition.go | 26 +++++++++++++++++--------- core/rawdb/schema.go | 1 - core/state/database_ubt.go | 8 ++------ core/state/reader.go | 6 +++++- params/config.go | 19 +++---------------- params/protocol_params.go | 8 +------- 8 files changed, 54 insertions(+), 52 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index ce3efe9426..882fb86927 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2149,18 +2149,18 @@ type ExecuteConfig struct { // transition, a transition UBTDatabase is built; otherwise (transition // ended, or chainConfig.UBTTransitionEndTime crossed) a plain UBTDatabase // is returned with the transition wrap disabled. -func (bc *BlockChain) stateDatabase(parentRoot common.Hash, header *types.Header) state.Database { +func (bc *BlockChain) stateDatabase(parentRoot common.Hash, header *types.Header) (state.Database, error) { if !bc.chainConfig.IsUBT(header.Number, header.Time) { - return state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) + return state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps), nil } // Past the configured transition end: pure binary, no wrap, no MPT base. if !bc.chainConfig.UBTTransitionActive(header.Number, header.Time) { - return state.NewUBTDatabase(bc.bintriedbOrMain(), bc.codedb).WithTransitionTreeWrap(false) + return state.NewUBTDatabase(bc.bintriedbOrMain(), bc.codedb).WithTransitionTreeWrap(false), nil } // First UBT block: parent is pre-UBT; seed the transition with parent root. parent := bc.GetHeaderByHash(header.ParentHash) if parent != nil && !bc.chainConfig.IsUBT(parent.Number, parent.Time) { - return state.NewTransitionUBTDatabase(bc.bintriedbOrMain(), bc.triedb, bc.codedb, parentRoot) + return state.NewTransitionUBTDatabase(bc.bintriedbOrMain(), bc.triedb, bc.codedb, parentRoot), nil } // Subsequent UBT block while the transition is active: probe the registry // to see whether a base root is still recorded. @@ -2180,18 +2180,21 @@ func (bc *BlockChain) bintriedbOrMain() *triedb.Database { // probeTransitionDatabase reads the transition registry from the binary trie // at the given root and chooses between a transition-mode UBTDatabase (if // the registry still records a base root) and a plain UBTDatabase otherwise. -func (bc *BlockChain) probeTransitionDatabase(root common.Hash) state.Database { +func (bc *BlockChain) probeTransitionDatabase(root common.Hash) (state.Database, error) { bindb := bc.bintriedbOrMain() plain := state.NewUBTDatabase(bindb, bc.codedb) reader, err := plain.StateReader(root) if err != nil { - return plain + return nil, err + } + ts, err := overlay.LoadTransitionState(storageReaderFunc(reader.Storage)) + if err != nil { + return nil, err } - ts := overlay.LoadTransitionState(storageReaderFunc(reader.Storage), root) if ts == nil || ts.Transitioned() || ts.BaseRoot == (common.Hash{}) { - return plain + return plain, nil } - return state.NewTransitionUBTDatabase(bindb, bc.triedb, bc.codedb, ts.BaseRoot) + return state.NewTransitionUBTDatabase(bindb, bc.triedb, bc.codedb, ts.BaseRoot), nil } // storageReaderFunc adapts a state-reader Storage method to overlay.StorageReader. @@ -2212,7 +2215,10 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, ) defer interrupt.Store(true) // terminate the prefetch at the end - sdb := bc.stateDatabase(parentRoot, block.Header()) + sdb, err := bc.stateDatabase(parentRoot, block.Header()) + if err != nil { + return nil, err + } // If prefetching is enabled, run that against the current state to pre-cache // transactions and probabilistically some of the account/storage trie nodes. // diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go index 4dd04b24c4..c9c6671b39 100644 --- a/core/blockchain_reader.go +++ b/core/blockchain_reader.go @@ -421,13 +421,21 @@ func (bc *BlockChain) State() (*state.StateDB, error) { // StateAt returns a new mutable state based on a particular point in time. func (bc *BlockChain) StateAt(header *types.Header) (*state.StateDB, error) { - return state.New(header.Root, bc.stateDatabase(header.Root, header)) + db, err := bc.stateDatabase(header.Root, header) + if err != nil { + return nil, err + } + return state.New(header.Root, db) } // StateAtForkBoundary returns a new mutable state based on the parent state // and the given header, handling the transition across the UBT fork. func (bc *BlockChain) StateAtForkBoundary(parent *types.Header, header *types.Header) (*state.StateDB, error) { - return state.New(parent.Root, bc.stateDatabase(parent.Root, header)) + db, err := bc.stateDatabase(parent.Root, header) + if err != nil { + return nil, err + } + return state.New(parent.Root, db) } // HistoricState returns a historic state specified by the given header. diff --git a/core/overlay/state_transition.go b/core/overlay/state_transition.go index 356c77b794..2976a0fda2 100644 --- a/core/overlay/state_transition.go +++ b/core/overlay/state_transition.go @@ -39,9 +39,8 @@ type StorageReader interface { Storage(addr common.Address, slot common.Hash) (common.Hash, error) } -// TransitionState holds the progress markers of the MPT-to-binary -// translation process. It is reconstructed on demand from the storage of the -// binary transition registry system contract. +// TransitionState is a structure that holds the progress markers of the +// translation process. type TransitionState struct { CurrentAccountAddress *common.Address // address of the last translated account CurrentSlotHash common.Hash // hash of the last translated storage slot @@ -98,10 +97,13 @@ func IsTransitionActive(reader StorageReader) bool { // // The root parameter is unused; it is retained on the signature so callers // can express the state version they intend to read. -func LoadTransitionState(reader StorageReader, root common.Hash) *TransitionState { +func LoadTransitionState(reader StorageReader) (*TransitionState, error) { started, err := reader.Storage(params.BinaryTransitionRegistryAddress, transitionStartedKey) - if err != nil || started == (common.Hash{}) { - return nil + if err != nil { + return nil, err + } + if started == (common.Hash{}) { + return nil, nil } ended, _ := reader.Storage(params.BinaryTransitionRegistryAddress, transitionEndedKey) @@ -114,8 +116,14 @@ func LoadTransitionState(reader StorageReader, root common.Hash) *TransitionStat currentAddr = &addr } - slotHash, _ := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressSlotKey) - storageProcessed, _ := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressStorageProcessed) + slotHash, err := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressSlotKey) + if err != nil { + return nil, err + } + storageProcessed, err := reader.Storage(params.BinaryTransitionRegistryAddress, conversionProgressStorageProcessed) + if err != nil { + return nil, err + } return &TransitionState{ Started: true, @@ -124,5 +132,5 @@ func LoadTransitionState(reader StorageReader, root common.Hash) *TransitionStat CurrentAccountAddress: currentAddr, CurrentSlotHash: slotHash, StorageProcessed: storageProcessed != (common.Hash{}), - } + }, nil } diff --git a/core/rawdb/schema.go b/core/rawdb/schema.go index d3f6944316..a5ebb961f7 100644 --- a/core/rawdb/schema.go +++ b/core/rawdb/schema.go @@ -457,4 +457,3 @@ func trienodeHistoryIndexBlockKey(addressHash common.Hash, path []byte, blockID return out } - diff --git a/core/state/database_ubt.go b/core/state/database_ubt.go index 437e2fdaf9..e17dd48b67 100644 --- a/core/state/database_ubt.go +++ b/core/state/database_ubt.go @@ -138,9 +138,7 @@ func (db *UBTDatabase) ReadersWithCacheStats(stateRoot common.Hash) (Reader, Rea return ra, rb, nil } -// OpenTrie opens the main account trie at a specific root hash. During an -// active transition, the binary trie is wrapped in a TransitionTrie so writes -// land on the binary trie while reads fall through to the frozen MPT base. +// OpenTrie opens the main account trie at a specific root hash. func (db *UBTDatabase) OpenTrie(root common.Hash) (Trie, error) { bt, err := bintrie.NewBinaryTrie(root, db.triedb) if err != nil { @@ -157,9 +155,7 @@ func (db *UBTDatabase) OpenTrie(root common.Hash) (Trie, error) { } // OpenStorageTrie opens the storage trie of an account. In binary trie mode -// the unified trie carries all state, so the main trie is reused. During the -// transition, an MPT storage trie is opened for accounts that have not yet -// been migrated. +// the unified trie carries all state, so the main trie is reused. func (db *UBTDatabase) OpenStorageTrie(stateRoot common.Hash, address common.Address, root common.Hash, self Trie) (Trie, error) { if self != nil && self.IsUBT() { return self, nil diff --git a/core/state/reader.go b/core/state/reader.go index 7b8ff35ac1..140dbc3b60 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -288,7 +288,11 @@ func newUBTTrieReader(root common.Hash, bindb *triedb.Database, mptdb *triedb.Da } var base *trie.StateTrie if wrapInTransitionTrie && mptdb != nil { - if ts := overlay.LoadTransitionState(&binTrieStorageReader{tr: binTrie}, root); ts != nil && ts.BaseRoot != (common.Hash{}) { + ts, err := overlay.LoadTransitionState(&binTrieStorageReader{tr: binTrie}) + if err != nil { + return nil, err + } + if ts != nil && ts.BaseRoot != (common.Hash{}) { base, err = trie.NewStateTrie(trie.StateTrieID(ts.BaseRoot), mptdb) if err != nil { return nil, err diff --git a/params/config.go b/params/config.go index 00752479d6..f02d239ad5 100644 --- a/params/config.go +++ b/params/config.go @@ -469,12 +469,8 @@ type ChainConfig struct { UBTTime *uint64 `json:"ubtTime,omitempty"` // UBT switch time (nil = no fork, 0 = already on UBT) // UBTTransitionEndTime is the timestamp at which the MPT-to-binary - // transition tree is no longer applied. While UBT is active and the - // timestamp is below this value (or nil), state access is wrapped in a - // TransitionTrie that overlays the binary trie on the frozen MPT base. - // Once headers reach this time, the transition wrapper is dropped and - // state is read directly from the binary trie. nil = wrapper stays on - // indefinitely. Mirrors the threshold semantics of TerminalTotalDifficulty. + // transition tree is no longer applied. Mirrors the threshold semantics + // of TerminalTotalDifficulty. UBTTransitionEndTime *uint64 `json:"ubtTransitionEndTime,omitempty"` // TerminalTotalDifficulty is the amount of total difficulty reached by @@ -897,16 +893,7 @@ func (c *ChainConfig) UBTTransitionActive(num *big.Int, time uint64) bool { return c.UBTTransitionEndTime == nil || time < *c.UBTTransitionEndTime } -// IsUBTGenesis checks whether the verkle fork is activated at the genesis block. -// -// Verkle mode is considered enabled if the verkle fork time is configured, -// regardless of whether the local time has surpassed the fork activation time. -// This is a temporary workaround for verkle devnet testing, where verkle is -// activated at genesis, and the configured activation date has already passed. -// -// In production networks (mainnet and public testnets), verkle activation -// always occurs after the genesis block, making this function irrelevant in -// those cases. +// IsUBTGenesis checks whether the UBT fork is activated at the genesis block. func (c *ChainConfig) IsUBTGenesis() bool { return c.EnableUBTAtGenesis } diff --git a/params/protocol_params.go b/params/protocol_params.go index 831d05a3b9..44a8cc89c3 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -224,13 +224,7 @@ var ( ConsolidationQueueCode = common.FromHex("3373fffffffffffffffffffffffffffffffffffffffe1460d35760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461019a57600182026001905f5b5f82111560685781019083028483029004916001019190604d565b9093900492505050366060146088573661019a573461019a575f5260205ff35b341061019a57600154600101600155600354806004026004013381556001015f358155600101602035815560010160403590553360601b5f5260605f60143760745fa0600101600355005b6003546002548082038060021160e7575060025b5f5b8181146101295782810160040260040181607402815460601b815260140181600101548152602001816002015481526020019060030154905260010160e9565b910180921461013b5790600255610146565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561017357505f5b6001546001828201116101885750505f61018e565b01600190035b5f555f6001556074025ff35b5f5ffd") // BinaryTransitionRegistryAddress is the system contract that exposes the - // MPT-to-binary transition state via storage slots: - // slot 0 = transition started flag - // slot 1 = address of the last translated account - // slot 2 = hash of the last translated storage slot - // slot 3 = "storage processed" flag - // slot 4 = transition ended flag - // slot 5 = frozen MPT base root + // MPT-to-binary transition state via storage slots. BinaryTransitionRegistryAddress = common.HexToAddress("0x1622162216221622162216221622162216221622") ) From b3d3dd70f5eb6ce4d1ca1dc6bec3d77e7941b334 Mon Sep 17 00:00:00 2001 From: Guillaume Ballet <3272758+gballet@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:44:09 +0200 Subject: [PATCH 6/6] reduce comment verbosity Co-authored-by: Guillaume Ballet <3272758+gballet@users.noreply.github.com> --- core/overlay/state_transition.go | 5 ----- core/state/database_ubt.go | 10 ---------- 2 files changed, 15 deletions(-) diff --git a/core/overlay/state_transition.go b/core/overlay/state_transition.go index 2976a0fda2..58680cae30 100644 --- a/core/overlay/state_transition.go +++ b/core/overlay/state_transition.go @@ -33,8 +33,6 @@ var ( ) // StorageReader is a minimal interface for reading contract storage slots. -// It is satisfied by *state.flatReader, allowing the transition state to be -// loaded without a full state.StateDB. type StorageReader interface { Storage(addr common.Address, slot common.Hash) (common.Hash, error) } @@ -94,9 +92,6 @@ func IsTransitionActive(reader StorageReader) bool { // transition registry system contract storage. Returns nil when the // registry has not been initialised (i.e. the chain has not yet reached the // UBT fork block). -// -// The root parameter is unused; it is retained on the signature so callers -// can express the state version they intend to read. func LoadTransitionState(reader StorageReader) (*TransitionState, error) { started, err := reader.Storage(params.BinaryTransitionRegistryAddress, transitionStartedKey) if err != nil { diff --git a/core/state/database_ubt.go b/core/state/database_ubt.go index e17dd48b67..287d683a8f 100644 --- a/core/state/database_ubt.go +++ b/core/state/database_ubt.go @@ -30,13 +30,6 @@ import ( ) // UBTDatabase is an implementation of Database interface for Unified Binary Trie. -// -// In its plain form it uses a single binary trie database. During the -// MPT-to-binary transition, an optional MPT trie database (mpttriedb) and a -// frozen base root provide read-only access to pre-transition state. The -// wrapInTransitionTrie flag controls whether reads at this database are -// served via a TransitionTrie that overlays the binary trie on the MPT -// base; it is normally driven by chainConfig.UBTTransitionActive. type UBTDatabase struct { triedb *triedb.Database mpttriedb *triedb.Database @@ -49,9 +42,6 @@ type UBTDatabase struct { func (db *UBTDatabase) Type() DatabaseType { return TypeUBT } // NewUBTDatabase creates a state database with the Unified binary trie manner. -// State access is wrapped in a TransitionTrie by default (which degenerates -// to a passthrough when there is no MPT base) so callers that don't care -// about the override get sensible defaults. func NewUBTDatabase(triedb *triedb.Database, codedb *CodeDB) *UBTDatabase { if codedb == nil { codedb = NewCodeDB(triedb.Disk())