diff --git a/.github/workflows/test-fuzz.yml b/.github/workflows/test-fuzz.yml index 50b30a56395..11eb91fe2de 100644 --- a/.github/workflows/test-fuzz.yml +++ b/.github/workflows/test-fuzz.yml @@ -70,6 +70,8 @@ jobs: - { name: patricia, pkg: db/seg/patricia, fn: FuzzPatricia } - { name: patricia-longest-match, pkg: db/seg/patricia, fn: FuzzLongestMatch } - { name: abi, pkg: execution/abi, fn: FuzzABI } + - { name: pbin-bitpath-codec, pkg: execution/commitment, fn: FuzzPBinBitPathCodec } + - { name: pbin-process-oracle, pkg: execution/commitment, fn: FuzzPBinProcessMatchesOracle } - { name: nibbles-hexcompact, pkg: execution/commitment/nibbles, fn: FuzzHexCompactRoundtrip } - { name: rlp, pkg: execution/types, fn: FuzzRLP } - { name: precompiles, pkg: execution/vm, fn: FuzzPrecompiledContracts } @@ -108,7 +110,10 @@ jobs: run: | mkdir -p "$ERIGON_BUILD/fuzz" echo "::group::go test -fuzz ${{ matrix.fn }} (./${{ matrix.pkg }}, ${FUZZTIME})" - go test "./${{ matrix.pkg }}/" -run '^$' -fuzz "^${{ matrix.fn }}$" -fuzztime "${FUZZTIME}" + # Minimization is capped: Go's 60s default lets an input found late in + # the run keep minimizing past the coordinator's deadline, which + # surfaces as "context deadline exceeded" rather than as the crash. + go test "./${{ matrix.pkg }}/" -run '^$' -fuzz "^${{ matrix.fn }}$" -fuzztime "${FUZZTIME}" -fuzzminimizetime 10s echo "::endgroup::" # Tell a genuine crash from an engine timeout. On a crash `go test` writes a diff --git a/AGENTS.md b/AGENTS.md index 03cdd307294..28d5e075175 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,14 @@ Commit messages: prefix with package(s) modified, e.g., `eth, rpc: make trace co Don't sign commits, pr's, issues, comments. +`package commitment` holds two engines in one namespace. Every package-level identifier belonging to the EIP-8297 binary trie carries a `pbin` prefix (`PBin` for exported ones) — the hex engine already owns the generic names (`cell`, `fold`, `unfold`, `computeCellHash`), so an unprefixed addition is a collision waiting to happen. Test helpers included. + +Selecting the binary trie is process-global, not a per-tester option: set `statecfg.ExperimentalBinCommitment` and `statecfg.BinCommitmentHash`, then `commitment.SetPBinHashSuite`. Calling `SetPBinHashSuite` alone is undone by the settings resolver's keccak default. A test that flips these must restore them in `t.Cleanup` and must not call `t.Parallel` — a concurrent hex test reads the same globals. + +The EIP-8297 embedding is not versioned on disk. `erigondb.toml` records `trie_variant` and `trie_hash` and guards a change of either, but nothing records which embedding wrote the state — so a change to key derivation or leaf layout silently recomputes different roots over an existing bin datadir. Rebuild bin datadirs from genesis whenever the embedding changes. + +Cite by name, never by line number. An EIP reference is `eip:"
"`, not `eip:NNN-NNN`; a reference to erigon source from `docs/` names the identifier and its file, not `file.go:NNN`. Line anchors rot on the next edit in either repo, and a stale one is worse than none — it points a reader at unrelated code with full confidence. + Run `make lint` before every push. The linter is non-deterministic — run it repeatedly until clean. **Important**: Always run `make lint` after making code changes and before committing. Fix any linter errors before proceeding. PRs must pass `make lint` before being opened or updated. diff --git a/cmd/integration/commands/commitment.go b/cmd/integration/commands/commitment.go index 4171dfbd8a7..006ca82fc7a 100644 --- a/cmd/integration/commands/commitment.go +++ b/cmd/integration/commands/commitment.go @@ -138,7 +138,7 @@ func init() { // commitment visualize cmdCommitmentVisualize.Flags().StringVar(&visualizeOutputDir, "output", "", "existing directory to store output HTML. By default, same as commitment files") cmdCommitmentVisualize.Flags().IntVarP(&visualizeConcurrency, "concurrency", "j", 4, "amount of concurrently processed files") - cmdCommitmentVisualize.Flags().StringVar(&visualizeTrieVariant, "trie", "hex", "commitment trie variant (values are hex and parallel)") + cmdCommitmentVisualize.Flags().StringVar(&visualizeTrieVariant, "trie", "hex", "commitment trie variant (hex or parallel)") cmdCommitmentVisualize.Flags().StringVar(&visualizeCompression, "compression", "none", "compression type (none, k, v, kv)") cmdCommitmentVisualize.Flags().BoolVar(&visualizePrintState, "state", false, "print state of file") cmdCommitmentVisualize.Flags().IntVar(&visualizeDepth, "depth", 0, "depth of the prefixes to analyze") @@ -1376,6 +1376,9 @@ func extractKVPairFromCompressed(filename string, keysSink chan commitment.Branc } defer dec.Close() tv := commitment.ParseTrieVariant(visualizeTrieVariant) + if tv == commitment.VariantBinPatriciaTrie { + return fmt.Errorf("commitment visualize decodes hex records only, not %s", tv) + } fc, err := seg.ParseFileCompression(visualizeCompression) if err != nil { diff --git a/cmd/integration/commands/flags.go b/cmd/integration/commands/flags.go index d5551a57689..df456391513 100644 --- a/cmd/integration/commands/flags.go +++ b/cmd/integration/commands/flags.go @@ -176,6 +176,8 @@ func withDataDir(cmd *cobra.Command) { func withExperimentalCommitment(cmd *cobra.Command) { cmd.Flags().BoolVar(&statecfg.ExperimentalParallelCommitment, utils.ExperimentalParallelCommitmentFlag.Name, statecfg.ExperimentalParallelCommitment, utils.ExperimentalParallelCommitmentFlag.Usage) cmd.Flags().BoolVar(&statecfg.ExperimentalStreamingCommitment, utils.ExperimentalStreamingCommitmentFlag.Name, statecfg.ExperimentalStreamingCommitment, utils.ExperimentalStreamingCommitmentFlag.Usage) + cmd.Flags().BoolVar(&statecfg.ExperimentalBinCommitment, utils.ExperimentalBinCommitmentFlag.Name, statecfg.ExperimentalBinCommitment, utils.ExperimentalBinCommitmentFlag.Usage) + cmd.Flags().StringVar(&statecfg.BinCommitmentHash, utils.ExperimentalBinCommitmentHashFlag.Name, statecfg.BinCommitmentHash, utils.ExperimentalBinCommitmentHashFlag.Usage) } func withBatchSize(cmd *cobra.Command) { diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 8f93251c314..b627cb47642 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -55,11 +55,13 @@ import ( "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/downloader/downloadercfg" "github.com/erigontech/erigon/db/snapcfg" + "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/db/version" "github.com/erigontech/erigon/diagnostics/metrics" "github.com/erigontech/erigon/execution/builder/buildercfg" "github.com/erigontech/erigon/execution/chain/networkname" chainspec "github.com/erigontech/erigon/execution/chain/spec" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/protocol/rules/ethash/ethashcfg" "github.com/erigontech/erigon/execution/state/genesiswrite" @@ -1174,6 +1176,23 @@ var ( Usage: "EXPERIMENTAL: enables streaming trie for commitment (StreamingCommitter, overlaps folding with execution). Takes precedence over --experimental.parallel-commitment if set.", Value: false, } + // ExperimentalBinCommitmentFlag selects the EIP-8297 binary commitment trie. + // A whole-datadir property: honoured on a fresh datadir, persisted to + // erigondb.toml there, and adopted from it on later starts. + ExperimentalBinCommitmentFlag = cli.BoolFlag{ + Name: "experimental.bin-commitment", + Usage: "EXPERIMENTAL: enables the EIP-8297 binary commitment trie. Takes effect on a fresh datadir only and is persisted there.", + Value: false, + } + // ExperimentalBinCommitmentHashFlag picks H for the binary trie. Persisted and + // adopted like the variant itself: roots do not survive a change. + ExperimentalBinCommitmentHashFlag = cli.StringFlag{ + Name: "experimental.bin-commitment.hash", + Usage: "EXPERIMENTAL: hash for the EIP-8297 binary commitment trie: \"keccak\" (default) or \"blake3\". blake3 matches the execution-specs reference and the other clients on the binary-trie testnets. Takes effect on a fresh datadir only and is persisted there.", + // Empty, not "keccak": an unset flag must stay distinguishable from an + // explicit one, which a hex datadir refuses. + Value: "", + } GDBMeFlag = cli.BoolFlag{ Name: "gdbme", Usage: "restart erigon under gdb for debug purposes", @@ -2069,6 +2088,24 @@ func SetEthConfig(nodeCtx context.Context, ctx *cli.Command, nodeConfig *nodecfg cfg.ExperimentalStreamingCommitment = true } + if ctx.Bool(ExperimentalBinCommitmentFlag.Name) { + cfg.ExperimentalBinCommitment = true + // The variant has to be process-wide before any genesis is computed here: + // dev mode derives the beacon Eth1Data from the EL genesis hash while still + // setting up the config, long before the backend applies the flag. + statecfg.ExperimentalBinCommitment = true + } + + if h := ctx.String(ExperimentalBinCommitmentHashFlag.Name); h != "" { + if err := commitment.SetPBinHashSuite(h); err != nil { + Fatalf("%v", err) + } + // Genesis is computed here too, so the suite has to be live before the + // datadir reconciles it. + cfg.BinCommitmentHash = h + statecfg.BinCommitmentHash = h + } + cfg.FcuTimeout = ctx.Duration(FcuTimeoutFlag.Name) cfg.FcuBackgroundPrune = ctx.Bool(FcuBackgroundPruneFlag.Name) cfg.FcuBackgroundCommit = ctx.Bool(FcuBackgroundCommitFlag.Name) diff --git a/common/dbg/experiments.go b/common/dbg/experiments.go index 0ff10651dc2..5c69d8a1657 100644 --- a/common/dbg/experiments.go +++ b/common/dbg/experiments.go @@ -67,6 +67,16 @@ var ( discardCommitment = EnvBool("DISCARD_COMMITMENT", false) pruneTotalDifficulty = EnvBool("PRUNE_TOTAL_DIFFICULTY", true) + // CheckHeaderStateRoot gates the post-execution comparison of the computed + // state root against the block header's. On by default; switch off only for + // a chain whose headers this node cannot reproduce — with it off nothing + // cross-checks execution results. + CheckHeaderStateRoot = EnvBool("CHECK_HEADER_STATE_ROOT", true) + + warnRootCheckOff = sync.OnceFunc(func() { + log.Warn("HEADER STATE-ROOT CHECK IS DISABLED (CHECK_HEADER_STATE_ROOT=false): nothing cross-checks execution results against headers; a wrong chain will look healthy") + }) + // force skipping of any non-Erigon2 .torrent files DownloaderOnlyBlocks = EnvBool("DOWNLOADER_ONLY_BLOCKS", false) @@ -163,6 +173,16 @@ func init() { } } +// WarnHeaderStateRootCheckDisabled says once per process that nothing +// cross-checks execution against headers. Node startup and the execution path +// both call it, so a runner that never boots a node still says so. +func WarnHeaderStateRootCheckDisabled() { + if CheckHeaderStateRoot { + return + } + warnRootCheckOff() +} + func ReadMemStats(m *runtime.MemStats) { if noMemstat { return diff --git a/db/integrity/commitment_integrity.go b/db/integrity/commitment_integrity.go index d5496ec79c2..8212bad90f1 100644 --- a/db/integrity/commitment_integrity.go +++ b/db/integrity/commitment_integrity.go @@ -212,7 +212,7 @@ func checkCommitmentRootViaFileData(ctx context.Context, tx kv.TemporalTx, br db func checkCommitmentRootViaSd(ctx context.Context, tx kv.TemporalTx, f state.VisibleFile, info commitmentRootInfo, logger log.Logger) (*execctx.SharedDomains, error) { maxTxNum := f.EndRootNum() - 1 - sd, err := execctx.NewSharedDomains(ctx, tx, logger, execctx.WithSequentialCommitment()) + sd, err := execctx.NewSharedDomains(ctx, tx, logger, execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } @@ -1109,7 +1109,7 @@ func CheckCommitmentHistAtBlk(ctx context.Context, db kv.TemporalRoDB, br dbserv return err } defer tx.Rollback() - sd, err := execctx.NewSharedDomains(ctx, tx, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sd, err := execctx.NewSharedDomains(ctx, tx, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return err } @@ -1177,7 +1177,7 @@ func CheckCommitmentHistAtBlkRange(ctx context.Context, sc SamplerCfg, db kv.Tem return err } defer tx.Rollback() - sd, err := execctx.NewSharedDomains(wCtx, tx, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sd, err := execctx.NewSharedDomains(wCtx, tx, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return err } @@ -1191,7 +1191,7 @@ func CheckCommitmentHistAtBlkRange(ctx context.Context, sc SamplerCfg, db kv.Tem for blockNum := range sampler.BlockNums(windowStart, windowEnd) { // Fresh SharedDomains per block: an SD is committed-or-closed, // never reset in place. - sd, err := execctx.NewSharedDomains(wCtx, tx, logger, execctx.WithoutDeferredBranchUpdates()) + sd, err := execctx.NewSharedDomains(wCtx, tx, logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return err } diff --git a/db/integrity/pbin_hex_only_test.go b/db/integrity/pbin_hex_only_test.go new file mode 100644 index 00000000000..4235077dac0 --- /dev/null +++ b/db/integrity/pbin_hex_only_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package integrity + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" +) + +func withBinCommitment(t *testing.T, on bool) { + t.Helper() + orig := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = orig }) + statecfg.ExperimentalBinCommitment = on +} + +// The history checks recompute roots with the hex trie: on a bin datadir they must +// refuse, not report a mismatch against correct bin records. +func TestPBinCommitmentHistChecksRefuseBin(t *testing.T) { + // No t.Parallel: mutates process-global statecfg flags. + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + withBinCommitment(t, true) + + err := CheckCommitmentHistAtBlk(t.Context(), db, nil, 1, log.LvlInfo, log.New()) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) + + sc, err := NewSamplerCfg(1, 1.0) + require.NoError(t, err) + err = CheckCommitmentHistAtBlkRange(t.Context(), sc, db, nil, 0, 1, log.New()) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) +} diff --git a/db/state/erigondb_settings.go b/db/state/erigondb_settings.go index 3eeb28df813..ac708b10c3d 100644 --- a/db/state/erigondb_settings.go +++ b/db/state/erigondb_settings.go @@ -1,6 +1,8 @@ package state import ( + "errors" + "fmt" "os" "path/filepath" @@ -10,14 +12,27 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/config3" "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" ) const ERIGONDB_SETTINGS_FILE = "erigondb.toml" +const ( + TrieVariantHex = "hex" + TrieVariantBin = "bin" +) + type ErigonDBSettings struct { StepSize uint64 `toml:"step_size"` StepsInFrozenFile uint64 `toml:"steps_in_frozen_file"` ReferencesInCommitmentBranches *bool `toml:"references_in_commitment_branches"` + // TrieVariant is the commitment trie the datadir was created with ("hex" or + // "bin"); absent means hex. Like every erigondb.toml key it wins over the CLI. + TrieVariant *string `toml:"trie_variant,omitempty"` + // TrieHash is H for a "bin" datadir ("keccak" or "blake3"); absent means + // keccak. Meaningless under "hex", which has no choice of hash. + TrieHash *string `toml:"trie_hash,omitempty"` } // RefsInCommitmentBranches resolves the commitment "references in branches" regime, @@ -29,6 +44,70 @@ func (s *ErigonDBSettings) RefsInCommitmentBranches() bool { return *s.ReferencesInCommitmentBranches } +// TrieVariantName resolves the persisted commitment trie variant, treating an +// absent field as the hex trie. +func (s *ErigonDBSettings) TrieVariantName() string { + if s.TrieVariant == nil || *s.TrieVariant == "" { + return TrieVariantHex + } + return *s.TrieVariant +} + +// TrieHashName resolves H for a bin datadir, treating an absent field as Keccak. +func (s *ErigonDBSettings) TrieHashName() string { + if s.TrieHash == nil || *s.TrieHash == "" { + return commitment.PBinHashKeccak + } + return *s.TrieHash +} + +// reconcileTrieVariant applies the datadir's trie variant to the process: a bin +// datadir turns the bin flag on process-wide, and a combination the bin engine +// cannot honour is refused rather than degraded to a wrong-root run. +func reconcileTrieVariant(s *ErigonDBSettings, logger log.Logger) error { + switch s.TrieVariantName() { + case TrieVariantBin: + if s.RefsInCommitmentBranches() { + return errors.New("trie_variant \"bin\" conflicts with references_in_commitment_branches = true") + } + if statecfg.ExperimentalStreamingCommitment || statecfg.ExperimentalParallelCommitment { + return errors.New("the bin commitment trie is sequential-only; drop --experimental.streaming-commitment / --experimental.parallel-commitment") + } + if !statecfg.ExperimentalBinCommitment { + logger.Info("datadir uses the bin commitment trie; enabling it for this process") + statecfg.ExperimentalBinCommitment = true + } + // The stored hash wins over the flag: every root on disk was built with it, + // so honouring a differing flag would silently produce a second tree. + stored := s.TrieHashName() + if statecfg.BinCommitmentHash != "" && statecfg.BinCommitmentHash != stored { + return fmt.Errorf("--experimental.bin-commitment.hash=%s: datadir was built with %q; the bin trie needs a fresh datadir to change hash", + statecfg.BinCommitmentHash, stored) + } + // Resolution runs per RPC request and per aggregator open, while the + // selected suite is read unsynchronized by every engine; only write it + // when it actually has to change. + if commitment.PBinHashSuiteName() != stored { + if err := commitment.SetPBinHashSuite(stored); err != nil { + return fmt.Errorf("erigondb.toml: %w", err) + } + } + case TrieVariantHex: + if s.TrieHash != nil { + return errors.New("erigondb.toml: trie_hash is meaningless under trie_variant \"hex\"") + } + if statecfg.ExperimentalBinCommitment { + return errors.New("--experimental.bin-commitment: datadir was created with the hex commitment trie; the bin trie needs a fresh datadir") + } + if statecfg.BinCommitmentHash != "" { + return errors.New("--experimental.bin-commitment.hash needs --experimental.bin-commitment") + } + default: + return fmt.Errorf("erigondb.toml: unknown trie_variant %q", s.TrieVariantName()) + } + return nil +} + func readErigonDBSettings(path string) (*ErigonDBSettings, error) { data, err := os.ReadFile(path) if err != nil { @@ -77,6 +156,9 @@ func ResolveErigonDBSettingsWithRefsDefault(dirs datadir.Dirs, logger log.Logger if err != nil { return nil, err } + if err := reconcileTrieVariant(settings, logger); err != nil { + return nil, err + } if refsFirstStart != nil { logger.Info("--commitment.plainValues ignored: erigondb.toml already exists", "references_in_commitment_branches", settings.RefsInCommitmentBranches()) @@ -84,7 +166,8 @@ func ResolveErigonDBSettingsWithRefsDefault(dirs datadir.Dirs, logger log.Logger // An absent field is resolved through RefsInCommitmentBranches(); the file is synced // snapshot metadata and must not be rewritten. logger.Info("erigondb settings", "step_size", settings.StepSize, "steps_in_frozen_file", settings.StepsInFrozenFile, - "references_in_commitment_branches", settings.RefsInCommitmentBranches()) + "references_in_commitment_branches", settings.RefsInCommitmentBranches(), + "trie_variant", settings.TrieVariantName(), "trie_hash", settings.TrieHashName()) return settings, nil } @@ -93,6 +176,17 @@ func ResolveErigonDBSettingsWithRefsDefault(dirs datadir.Dirs, logger log.Logger refs = *refsFirstStart } + var trieVariant, trieHash *string + if statecfg.ExperimentalBinCommitment { + v := TrieVariantBin + trieVariant = &v + h := statecfg.BinCommitmentHash + if h == "" { + h = commitment.PBinHashKeccak + } + trieHash = &h + } + preverifiedExists, err := dir.FileExist(filepath.Join(dirs.Snap, datadir.PreverifiedFileName)) if err != nil { return nil, err @@ -100,6 +194,9 @@ func ResolveErigonDBSettingsWithRefsDefault(dirs datadir.Dirs, logger log.Logger // Legacy datadir (Erigon <= 3.3): write legacy settings so erigondb.toml exists on disk. if preverifiedExists { + if statecfg.ExperimentalBinCommitment { + return nil, errors.New("--experimental.bin-commitment: this datadir already has hex commitment state; the bin trie needs a fresh datadir") + } settings := &ErigonDBSettings{ StepSize: config3.LegacyStepSize, StepsInFrozenFile: config3.LegacyStepsInFrozenFile, @@ -119,12 +216,22 @@ func ResolveErigonDBSettingsWithRefsDefault(dirs datadir.Dirs, logger log.Logger StepSize: config3.DefaultStepSize, StepsInFrozenFile: config3.DefaultStepsInFrozenFile, ReferencesInCommitmentBranches: &refs, + TrieVariant: trieVariant, + TrieHash: trieHash, } - if noDownloader { + if err := reconcileTrieVariant(settings, logger); err != nil { + return nil, err + } + // A bin datadir persists its variant right away even with a downloader running: + // no published snapshot set carries a bin erigondb.toml, and leaving the variant + // unpersisted lets the empty preverified.toml that the snapshots stage commits for + // a chain without published hashes read as a legacy datadir at the next resolve. + if noDownloader || trieVariant != nil { // No downloader to provide the real file — write defaults to disk now. logger.Info("Initializing erigondb.toml with DEFAULT settings (nodownloader)", "step_size", settings.StepSize, "steps_in_frozen_file", settings.StepsInFrozenFile, - "references_in_commitment_branches", settings.RefsInCommitmentBranches()) + "references_in_commitment_branches", settings.RefsInCommitmentBranches(), + "trie_variant", settings.TrieVariantName()) if err := writeErigonDBSettings(settingsPath, settings); err != nil { return nil, err } diff --git a/db/state/execctx/commitment_flag_test.go b/db/state/execctx/commitment_flag_test.go index ef11b126c63..4fbf60d5e78 100644 --- a/db/state/execctx/commitment_flag_test.go +++ b/db/state/execctx/commitment_flag_test.go @@ -122,6 +122,19 @@ func TestPickTrieVariant_StreamingFlag(t *testing.T) { require.Equal(t, commitment.VariantParallelHexPatricia, execctx.PickTrieVariant()) } +func TestPickTrieVariant_BinFlag(t *testing.T) { + // No t.Parallel: mutates process-global statecfg flags. + origBin := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = origBin }) + + statecfg.ExperimentalBinCommitment = true + require.Equal(t, commitment.VariantBinPatriciaTrie, execctx.PickTrieVariant()) + + // Bin is a persisted datadir property, so it wins over the runtime experiments. + withCommitmentFlag(t, commitment.VariantStreamingHexPatricia) + require.Equal(t, commitment.VariantBinPatriciaTrie, execctx.PickTrieVariant()) +} + func TestSharedDomains_StreamingFlag_RootEquivalence(t *testing.T) { if testing.Short() { t.Skip() diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 311cab6301d..e04132e6270 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -287,8 +287,12 @@ type SharedDomains struct { func PickTrieVariant() commitment.TrieVariant { switch { // Selecting more than one experimental-commitment flag is a misconfiguration; - // they are alternative paths. Streaming overlaps folding with execution, so it + // they are alternative paths. Bin is a persisted whole-datadir property, so + // it wins over the runtime experiments (the settings resolver refuses the + // combination outright); streaming overlaps folding with execution, so it // wins over parallel. + case statecfg.ExperimentalBinCommitment: + return commitment.VariantBinPatriciaTrie case statecfg.ExperimentalStreamingCommitment: return commitment.VariantStreamingHexPatricia case statecfg.ExperimentalParallelCommitment: @@ -306,6 +310,14 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, for _, opt := range opts { opt(&o) } + if o.trieCfg.Variant == commitment.VariantBinPatriciaTrie { + if o.hexCommitmentOnly { + return nil, ErrBinCommitmentUnsupported + } + // Bit-path branch keys collide in the cache's hex-shaped trunk slots; the + // commitment-context ctor refuses a bin SD that shares the cache. + WithoutSharedBranchCache()(&o) + } trieCfg := o.trieCfg sd := &SharedDomains{ @@ -899,6 +911,10 @@ func (sd *SharedDomains) IndexAdd(table kv.InvertedIdx, key []byte, txNum uint64 func (sd *SharedDomains) StepSize() uint64 { return sd.stepSize } +// HasSharedBranchCache reports whether commitment-branch reads go through the +// aggregator-scope BranchCache shared across SharedDomains instances. +func (sd *SharedDomains) HasSharedBranchCache() bool { return sd.branchCache != nil } + // IsUnfrozenStepEdge reports whether txNum is the last tx of a step whose // commitment is not yet frozen into files — where a step-boundary checkpoint // must be written. diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 853ad487c54..8ce400ee81a 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -16,11 +16,20 @@ package execctx -import "github.com/erigontech/erigon/execution/commitment" +import ( + "errors" + + "github.com/erigontech/erigon/execution/commitment" +) + +// ErrBinCommitmentUnsupported is returned by NewSharedDomains for a caller that +// declared itself hex-only (WithHexCommitmentOnly) over a bin-variant datadir. +var ErrBinCommitmentUnsupported = errors.New("this code path supports the hex commitment trie only, and the datadir uses the bin trie") type sharedDomainOptions struct { trieCfg commitment.TrieConfig useSharedBranchCache bool + hexCommitmentOnly bool } // SharedDomainOption configures NewSharedDomains. @@ -41,9 +50,26 @@ func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } -// WithSequentialCommitment forces the sequential HexPatriciaHashed trie regardless -// of the experimental parallel/concurrent flags — for one-shot / empty-DB paths -// (e.g. genesis) that wire no trie-context factory for the parallel trie. -func WithSequentialCommitment() SharedDomainOption { - return func(o *sharedDomainOptions) { o.trieCfg.Variant = commitment.VariantHexPatriciaTrie } +// WithoutParallelCommitment demotes the experimental parallel/streaming tries to the +// sequential HexPatriciaHashed — for one-shot / empty-DB paths (e.g. genesis) that +// wire no trie-context factory for the parallel trie. The bin variant is a persisted +// whole-datadir property and stays bin: demoting it would compute a hex root over a +// datadir the executor reads as bin. +func WithoutParallelCommitment() SharedDomainOption { + return func(o *sharedDomainOptions) { + if o.trieCfg.Variant != commitment.VariantBinPatriciaTrie { + o.trieCfg.Variant = commitment.VariantHexPatriciaTrie + } + } +} + +// WithHexCommitmentOnly is WithoutParallelCommitment for callers that can only read +// hex branch records — eth_getProof, eth_getWitness, eth_simulateV1, receipt +// regeneration, commitment integrity. Under the bin variant NewSharedDomains returns +// ErrBinCommitmentUnsupported instead of reading bit-path records as hex ones. +func WithHexCommitmentOnly() SharedDomainOption { + return func(o *sharedDomainOptions) { + o.hexCommitmentOnly = true + WithoutParallelCommitment()(o) + } } diff --git a/db/state/execctx/pbin_options_test.go b/db/state/execctx/pbin_options_test.go new file mode 100644 index 00000000000..8d1e4dae5f7 --- /dev/null +++ b/db/state/execctx/pbin_options_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package execctx_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" +) + +// Mutates a process-global flag, so no test using it may run in parallel. +func withBinCommitmentFlag(t *testing.T, on bool) { + t.Helper() + orig := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = orig }) + statecfg.ExperimentalBinCommitment = on +} + +// Bin is a persisted datadir property, so WithoutParallelCommitment demotes only the +// experimental parallel/streaming tries: demoting bin would give a hex block-0 root. +func TestPBinWithoutParallelCommitmentKeepsBin(t *testing.T) { + for _, tc := range []struct { + name string + flag commitment.TrieVariant + want commitment.TrieVariant + }{ + {"hex", commitment.VariantHexPatriciaTrie, commitment.VariantHexPatriciaTrie}, + {"streaming", commitment.VariantStreamingHexPatricia, commitment.VariantHexPatriciaTrie}, + {"parallel", commitment.VariantParallelHexPatricia, commitment.VariantHexPatriciaTrie}, + {"bin", commitment.VariantBinPatriciaTrie, commitment.VariantBinPatriciaTrie}, + } { + t.Run(tc.name, func(t *testing.T) { + withBinCommitmentFlag(t, tc.flag == commitment.VariantBinPatriciaTrie) + withCommitmentFlag(t, tc.flag) + + db := newTestDb(t, 16) + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(t.Context(), tx, log.New(), execctx.WithoutParallelCommitment()) + require.NoError(t, err) + defer sd.Close() + + require.Equal(t, tc.want, sd.GetCommitmentCtx().Trie().Variant()) + }) + } +} + +// WithHexCommitmentOnly callers can only read hex branch records, so a bin datadir +// must fail loudly instead of having its bit-path records read as hex ones. +func TestPBinHexOnlyCommitmentRefusesBin(t *testing.T) { + withBinCommitmentFlag(t, true) + + db := newTestDb(t, 16) + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(t.Context(), tx, log.New(), execctx.WithHexCommitmentOnly()) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) + require.Nil(t, sd) +} + +func TestPBinHexOnlyCommitmentDemotesParallel(t *testing.T) { + withBinCommitmentFlag(t, false) + withCommitmentFlag(t, commitment.VariantParallelHexPatricia) + + db := newTestDb(t, 16) + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(t.Context(), tx, log.New(), execctx.WithHexCommitmentOnly()) + require.NoError(t, err) + defer sd.Close() + + require.Equal(t, commitment.VariantHexPatriciaTrie, sd.GetCommitmentCtx().Trie().Variant()) +} diff --git a/db/state/pbin_variant_persist_test.go b/db/state/pbin_variant_persist_test.go new file mode 100644 index 00000000000..cb47fbed63d --- /dev/null +++ b/db/state/pbin_variant_persist_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package state + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/config3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" +) + +// Mutates process-wide statecfg flags, so no test in this file may run in parallel. +func pbinWithVariantFlags(t *testing.T, bin, streaming, parallel bool) { + t.Helper() + origBin := statecfg.ExperimentalBinCommitment + origStream := statecfg.ExperimentalStreamingCommitment + origPar := statecfg.ExperimentalParallelCommitment + t.Cleanup(func() { + statecfg.ExperimentalBinCommitment = origBin + statecfg.ExperimentalStreamingCommitment = origStream + statecfg.ExperimentalParallelCommitment = origPar + }) + statecfg.ExperimentalBinCommitment = bin + statecfg.ExperimentalStreamingCommitment = streaming + statecfg.ExperimentalParallelCommitment = parallel +} + +func pbinWriteToml(t *testing.T, dirs datadir.Dirs, content string) string { + t.Helper() + path := filepath.Join(dirs.Snap, ERIGONDB_SETTINGS_FILE) + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + return path +} + +func TestPBinVariantFirstStartPersistsBin(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + + settings, err := ResolveErigonDBSettings(dirs, log.New(), true) + require.NoError(t, err) + require.Equal(t, TrieVariantBin, settings.TrieVariantName()) + + written, err := readErigonDBSettings(filepath.Join(dirs.Snap, ERIGONDB_SETTINGS_FILE)) + require.NoError(t, err) + require.NotNil(t, written.TrieVariant) + require.Equal(t, TrieVariantBin, *written.TrieVariant) +} + +func TestPBinVariantHexFirstStartWritesNoVariantKey(t *testing.T) { + pbinWithVariantFlags(t, false, false, false) + dirs := datadir.New(t.TempDir()) + + settings, err := ResolveErigonDBSettings(dirs, log.New(), true) + require.NoError(t, err) + require.Equal(t, TrieVariantHex, settings.TrieVariantName()) + + raw, err := os.ReadFile(filepath.Join(dirs.Snap, ERIGONDB_SETTINGS_FILE)) + require.NoError(t, err) + require.NotContains(t, string(raw), "trie_variant") +} + +func TestPBinVariantFlaglessRestartStaysBin(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + _, err := ResolveErigonDBSettings(dirs, log.New(), true) + require.NoError(t, err) + + // Flagless restart: the persisted trie_variant wins over the CLI default, process-wide. + statecfg.ExperimentalBinCommitment = false + settings, err := ResolveErigonDBSettings(dirs, log.New(), true) + require.NoError(t, err) + require.Equal(t, TrieVariantBin, settings.TrieVariantName()) + require.True(t, statecfg.ExperimentalBinCommitment) + require.Equal(t, commitment.VariantBinPatriciaTrie, execctx.PickTrieVariant()) +} + +func TestPBinVariantHexDatadirRefusesBinFlag(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + + for name, content := range map[string]string{ + "absent_field": "step_size = 100\nsteps_in_frozen_file = 8\n", + "explicit_hex": "step_size = 100\nsteps_in_frozen_file = 8\ntrie_variant = \"hex\"\n", + } { + t.Run(name, func(t *testing.T) { + dirs := datadir.New(t.TempDir()) + pbinWriteToml(t, dirs, content) + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "datadir was created with the hex commitment trie") + }) + } +} + +func TestPBinVariantBinDatadirRefusesStreamingAndParallel(t *testing.T) { + const binToml = "step_size = 100\nsteps_in_frozen_file = 8\ntrie_variant = \"bin\"\n" + + t.Run("streaming", func(t *testing.T) { + pbinWithVariantFlags(t, false, true, false) + dirs := datadir.New(t.TempDir()) + pbinWriteToml(t, dirs, binToml) + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "sequential-only") + }) + t.Run("parallel", func(t *testing.T) { + pbinWithVariantFlags(t, false, false, true) + dirs := datadir.New(t.TempDir()) + pbinWriteToml(t, dirs, binToml) + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "sequential-only") + }) +} + +func TestPBinVariantRefusesReferences(t *testing.T) { + t.Run("persisted", func(t *testing.T) { + pbinWithVariantFlags(t, false, false, false) + dirs := datadir.New(t.TempDir()) + pbinWriteToml(t, dirs, "step_size = 100\nsteps_in_frozen_file = 8\nreferences_in_commitment_branches = true\ntrie_variant = \"bin\"\n") + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "references_in_commitment_branches") + }) + t.Run("first_start", func(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + refs := true + _, err := ResolveErigonDBSettingsWithRefsDefault(dirs, log.New(), true, &refs) + require.ErrorContains(t, err, "references_in_commitment_branches") + }) +} + +func TestPBinVariantLegacyDatadirRefusesBin(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + require.NoError(t, os.WriteFile(filepath.Join(dirs.Snap, datadir.PreverifiedFileName), []byte(""), 0644)) + + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "already has hex commitment state") +} + +func TestPBinVariantUnknownVariantRefused(t *testing.T) { + pbinWithVariantFlags(t, false, false, false) + dirs := datadir.New(t.TempDir()) + pbinWriteToml(t, dirs, "step_size = 100\nsteps_in_frozen_file = 8\ntrie_variant = \"verkle\"\n") + + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "unknown trie_variant") +} + +func TestPBinVariantFreshWithDownloaderPersistsBin(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + + settings, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.NoError(t, err) + require.Equal(t, TrieVariantBin, settings.TrieVariantName()) + + written, err := readErigonDBSettings(filepath.Join(dirs.Snap, ERIGONDB_SETTINGS_FILE)) + require.NoError(t, err, "a bin datadir must persist its variant at first start, downloader or not") + require.Equal(t, TrieVariantBin, written.TrieVariantName()) + require.Equal(t, uint64(config3.DefaultStepSize), written.StepSize) +} + +// The snapshots stage writes an empty preverified.toml for a chain with no published +// snapshot hashes. Without a persisted variant that reads as a legacy datadir at the +// next resolve, and the bin run gets refused on its own fresh datadir. +func TestPBinVariantSurvivesEmptyPreverifiedFromSnapshotsStage(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dirs.Snap, datadir.PreverifiedFileName), []byte(""), 0644)) + + settings, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.NoError(t, err) + require.Equal(t, TrieVariantBin, settings.TrieVariantName()) +} + +func TestPBinVariantFreshWithDownloaderRefusesDeliveredHexToml(t *testing.T) { + pbinWithVariantFlags(t, true, false, false) + dirs := datadir.New(t.TempDir()) + + _, err := ResolveErigonDBSettings(dirs, log.New(), false) + require.NoError(t, err) + + // A downloader-delivered hex toml overwrites the persisted bin one; the next + // resolve must refuse rather than silently adopt hex. + pbinWriteToml(t, dirs, "step_size = 100\nsteps_in_frozen_file = 8\n") + _, err = ResolveErigonDBSettings(dirs, log.New(), false) + require.ErrorContains(t, err, "datadir was created with the hex commitment trie") +} diff --git a/db/state/squeeze.go b/db/state/squeeze.go index 5fc49944dd2..cf4f35230bf 100644 --- a/db/state/squeeze.go +++ b/db/state/squeeze.go @@ -33,7 +33,6 @@ import ( "github.com/erigontech/erigon/db/seg" downloadertype "github.com/erigontech/erigon/db/snaptype" "github.com/erigontech/erigon/db/state/execctx" - "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/commitment/commitmentdb" "github.com/erigontech/erigon/execution/stagedsync/stages" @@ -1020,15 +1019,7 @@ func RebuildCommitmentFiles(ctx context.Context, rwDb kv.TemporalRwDB, txNumsRea } roTx.Rollback() - streaming := statecfg.ExperimentalStreamingCommitment - parallel := statecfg.ExperimentalParallelCommitment - trieVariant := commitment.VariantHexPatriciaTrie - switch { - case streaming: - trieVariant = commitment.VariantStreamingHexPatricia - case parallel: - trieVariant = commitment.VariantParallelHexPatricia - } + trieVariant := execctx.PickTrieVariant() for shardFrom < lastShard { // recreate this file range 1+ steps nextKey := func() (ok bool, k []byte) { @@ -1063,7 +1054,7 @@ func RebuildCommitmentFiles(ctx context.Context, rwDb kv.TemporalRwDB, txNumsRea domains.SetTxNum(lastTxnumInShard - 1) currentTxNum := lastTxnumInShard - 1 domains.GetCommitmentCtx().SetStateReader(commitmentdb.NewFilesOnlyStateReader(rwTx, lastTxnumInShard-1)) - if parallel || streaming { + if trieVariant == commitment.VariantParallelHexPatricia || trieVariant == commitment.VariantStreamingHexPatricia { domains.EnableParaTrieDB(rwDb) } @@ -1177,9 +1168,13 @@ func rebuildCommitmentShard(ctx context.Context, sd *execctx.SharedDomains, tx k sf := time.Now() var processed uint64 + // next() signals "no more keys" as (false, nil) but a shard boundary as + // (false, key), so the key has to be checked separately from ok. for ok, key := next(); ; ok, key = next() { - sd.GetCommitmentCtx().TouchKey(kv.AccountsDomain, string(key), nil) - processed++ + if len(key) > 0 { + sd.GetCommitmentCtx().TouchKey(kv.AccountsDomain, string(key), nil) + processed++ + } if !ok { break } diff --git a/db/state/statecfg/state_schema.go b/db/state/statecfg/state_schema.go index 2d4ec5894af..63155456798 100644 --- a/db/state/statecfg/state_schema.go +++ b/db/state/statecfg/state_schema.go @@ -207,6 +207,18 @@ var ExperimentalParallelCommitment = dbg.EnvBool("COMMITMENT_PARALLEL", false) // ExperimentalParallelCommitment. var ExperimentalStreamingCommitment = false +// ExperimentalBinCommitment selects the EIP-8297 binary commitment trie +// (commitment.ModeDirect + VariantBinPatriciaTrie). A whole-datadir property: +// persisted to erigondb.toml on first start and adopted from it on later +// starts, so a flagless restart of a bin datadir stays bin. +var ExperimentalBinCommitment = dbg.EnvBool("COMMITMENT_BIN", false) + +// BinCommitmentHash names H for the binary trie ("keccak" or "blake3", empty +// meaning keccak). Persisted and adopted exactly like ExperimentalBinCommitment: +// roots are incomparable across a change, so a datadir keeps the hash it was +// built with. +var BinCommitmentHash = dbg.EnvString("COMMITMENT_BIN_HASH", "") + var Schema = SchemaGen{ AccountsDomain: DomainCfg{ Name: kv.AccountsDomain, ValuesTable: kv.TblAccountVals, diff --git a/docs/fuzzing.md b/docs/fuzzing.md index da384217cf4..06d1497c838 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -32,6 +32,7 @@ Two things run these fuzzers: | `db/seg` | `FuzzCompress`, `FuzzDecompressMatch` | | `db/seg/patricia` | `FuzzPatricia`, `FuzzLongestMatch` | | `execution/abi` | `FuzzABI` | +| `execution/commitment` | `FuzzPBinBitPathCodec`, `FuzzPBinProcessMatchesOracle` | | `execution/commitment/nibbles` | `FuzzHexCompactRoundtrip` | | `execution/types` | `FuzzRLP` | | `execution/vm` | `FuzzPrecompiledContracts` | diff --git a/docs/pbin-encoding.md b/docs/pbin-encoding.md new file mode 100644 index 00000000000..32cd7c4ae0d --- /dev/null +++ b/docs/pbin-encoding.md @@ -0,0 +1,901 @@ +# How erigon encodes the EIP-8297 partitioned binary tree + +All file references are relative to `execution/commitment/` and name an identifier rather than a +line number, which drifts on every edit. Every identifier belonging to this engine carries a `pbin` +prefix; the hex MPT engine lives in the same package and owns the unprefixed names. + +The engine is `PBinPatriciaHashed` (`pbin_patricia_hashed.go`). It borrows the hex engine's +grid/unfold/fold skeleton and none of its node model: arity 2, no extension node, no storage root, +and a leaf commits its complete tree key (the type's doc comment). + +Everything below was produced by running the engine. Hex is real. + +--- + +## 1. Tree keys + +A tree key is `zone(1) || treePosition || subIndex(1)`, assembled by `pbinTreeKey` +(`pbin_keys.go`). Three zones exist, each admitting exactly one key length +(`pbinZoneKeyLength`, `pbin_keys.go`): + +| zone | name | key length | treePosition | +|------|---------|-----------:|---------------------------------| +| 0x00 | account | 34 | `stem = H(addr32)` | +| 0x01 | code | 34 | `H(codeHash \|\| 0*24 \|\| u64BE(codeIndex))` | +| 0xFF | storage | 66 | `stem \|\| H(addr32 \|\| u256BE(slotIndex))` | + +The two indexes are unrelated quantities, and neither preimage is a bare concatenation of +naturally-sized values — both are exactly 64 bytes, with the index widened to fill the tail: + +- `codeIndex = chunkID / 256`, the chunk's code group, written as 8 big-endian bytes after 24 zero + bytes (§10, `codeChunkKey`, `pbin_keys.go`). No address takes part: the code zone is + content-addressed, so two accounts running the same bytecode share one set of leaves. +- `slotIndex = slot >> 8`, written as a 32-byte big-endian value, which is `0x00 || slot[0:31]` + (§9, `groupDigest`, `pbin_keys.go`). + +The trailing `subIndex` byte is `chunkID % 256` for code and `slot & 0xFF` for storage. + +Zones `0x02..0xFE` have no length and `pbinTreeKey` panics on them. The fixed length per zone *is* +the prefix-free invariant, and it is re-asserted at hash time from the key's own first byte +(`leafCellHash`, `pbin_hash.go`) so a malformed key cannot reach the hasher. + +`addr32` is the 20-byte address left-zero-padded to 32 (`pbinAddr32`, `pbin_keys.go`). +`H` is Keccak-256 by default, blake3 under `--experimental.bin-commitment.hash` +(`SetPBinHashSuite`, `pbin_hash.go`). Key derivation and node hashing both use `H`, and +`setHashSuite` (`pbin_patricia_hashed.go`) swaps both seams at once so neither can be configured +alone. + +Two digests are memoized per `pbinDigestCache` (`pbin_keys.go`): the stem, keyed on +`addr32`, and the storage group hash, keyed on `(addr32, slot[0:31])`. The group entry is bound to +the address as well as the index, so an address change cannot yield a stale hit. + +## 2. `pbinBitpath` + +A path through the tree is up to 528 bits — the longest key, a 66-byte storage leaf +(`pbinMaxPathBits`, `pbin_bitpath.go`). It is held as nine big-endian words plus a bit count +(`pbinBitpath`, `pbin_bitpath.go`): + +``` +bit index 0 63 64 127 ... 512 527 + +------------------+------------------+ ... +----------------+ + | w[0] | w[1] | | w[8], 16 used | + +------------------+------------------+ ... +----------------+ + MSB first MSB first bits 512..527 +``` + +Bit `d` lives in `w[d/64]` at shift `63-(d%64)` (`bit`, `setBitAt`); source byte `i` loads into +`w[i/8] << (56-8*(i%8))` (`pbinPathFromBits`). Word order therefore equals descent order and +divergence is XOR plus `LeadingZeros64` with no reversal — the reason for the layout +(`pbinBitpath`'s doc comment). + +``` +key = 00b10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf600 (34 B) +bitLen = 272 +w[0] = 00b10e2d52761207 w[1] = 3b26eecdfd717e6a w[2] = 320cf44b4afac2b0 +w[3] = 732d9fcbe2b7fa0c w[4] = f600000000000000 w[5..8] = 0 +``` + +`bitLen` is the only authority on length. `bit` panics past it, and `appendPackedBits` emits +`ceil(bitLen/8)` bytes and re-masks the last. + +**Masking invariant.** Every path this engine builds holds zero bits at and past `bitLen`. +`maskTail` enforces it, called from `pbinPathFromBits`, `truncate` and the canonicality check in +`pbinDecodeBitPath`; `slice`, `appendBit` and `append` preserve it by construction, writing only +bits below the new `bitLen`. `setBitAt` is the one mutator that *can* dirty the tail — it is +bounded by `pbinMaxPathBits`, not by `bitLen` — and every caller writes inside the path. The type's +own doc comment is weaker, allowing anything past `bitLen`: read it as what a reader may assume, +not as what the constructors produce. The invariant is not what makes the common-prefix scan safe +— that is the `limit` clamp in `pbinCommonPrefixBitsAt`, pinned against a deliberately dirty tail +at `TestPBinCommonPrefixBits_IgnoresBitsBeyondBitLen` (`pbin_bitpath_test.go`). What depends on it +is struct equality: paths and cells are compared with `==`, and `pbinDecodeBitPath` rejects a +non-canonical key by masking a copy and comparing words. + +`pbinCommonPrefixBitsAt(key, from, prefix)` counts agreeing bits between `key` read +from bit `from` and `prefix` read from bit 0. The asymmetry exists because the descent compares a +whole tree key against a cell prefix that starts partway down: + +``` + from=4 +key w[wi] : b b b b|X X X X X X X X ... << 4 + w[wi+1] : h h h h| ... >> 60, spliced in +prefix w[0] : X X X X X X X X X X X X ... word-aligned + ^ XOR, LeadingZeros64 = agreeing bits in this word +``` + +## 3. Three prefix encodings + +The same bit string is spelled three different ways depending on where it is written. + +| where | count | layout | code | +|---|---|---|---| +| node preimage | `u16` big-endian, leading | `u16(bitLen) \|\| packed` | `pbinAppendBitPrefix`, `pbin_hash.go` | +| cell in a branch record | uvarint, leading | `uvarint(bitLen) \|\| packed` | `pbinAppendCell`, `pbin_branch.go` | +| domain key | one byte `bitLen mod 8`, **trailing** | `packed \|\| byte(bitLen%8)` | `pbinAppendBitPath`, `pbin_bitpath.go` | + +``` +encode_bit_prefix domain key + 0 bits 0000 0 bits 00 + 1 bit '1' 000180 1 bit 0x80 8001 + 3 bits '101' 0003a0 3 bits 0xE0 e003 + 7 bits all-1 0007fe 7 bits 0xB0 b007 + 8 bits 0xAA 0008aa 8 bits 0xB1 b100 <- mod 8 == 0 + 9 bits 0009aa80 9 bits 0xB180 b18001 +528 bits all-1 0210 || ff*66 +``` + +The preimage count is what keeps a 7-bit prefix from colliding with an 8-bit one that agrees with it +on the pad bit (`pbinAppendBitPrefix`'s doc comment). + +The domain key puts its count *last* so a subtree stays contiguous in the keyspace: every descendant +of a `b`-bit path repeats its first `floor(b/8)` whole packed bytes and the leading `b mod 8` bits +of the next, so the whole subtree lands in one byte-range. A leading length field would sort by +depth first and scatter that range (`pbinAppendBitPath`'s doc comment). Contiguity is all the +layout buys — the order inside the range is **not** ancestors-before-descendants, and the comment +says so. +Counterexample, measured: + +``` +7 bits 1111111 -> fe07 +8 bits 11111110 -> fe00 fe00 < fe07, yet the 7-bit path is a prefix of the 8-bit one +``` + +Nothing range-scans the domain today: every access is a point lookup by exact key +(`unfoldBranchNode`, `foldBranch`, `foldDelete`, `materializeBranch`). Contiguity is a property a +future scan could rely on, not one anything currently depends on. + +`pbinDecodeBitPath` is total and canonical — one path, one key. It rejects an empty buffer, a tail +byte above 7, a non-zero tail with no payload, over 528 bits and set pad bits. Bijectivity is +fuzz-pinned by `FuzzPBinBitPathCodec` (`pbin_bitpath_test.go`). + +## 4. Node preimages + +Two shapes, distinguished by a leading tag byte (`pbinLeafTag` / `pbinBranchTag`, `pbin_hash.go`). + +``` +leaf 0x00 || tree key (34 or 66) || value (32) leafCellHash +branch 0x01 || u16(bitLen) || packed prefix || left(32) || right(32) + branchHash +``` + +A leaf carries its **complete** tree key, not a suffix — `leafCellHash` concatenates the descent +path with the cell's own prefix and requires the result to be whole bytes (`pbin_hash.go`). +A branch's prefix here is **relative**: the bits between the parent's split and this node's own, +cut at fold time by `pph.currentKey.slice(upDepth, depth-1)` (`foldBranch`). The +domain key for that same node holds the *absolute* path. They coincide only at the top. + +An absent child hashes as 32 zero bytes and is never omitted (`pbinEmptyTreeHash`, `branchHash`) — +`pbinEmptyTreeHash`, deliberately not `empty.RootHash`, which would build a different tree. So the +empty tree's root is 32 zero bytes, and a one-key tree's root is the leaf hash itself with no branch +wrapping it (`RootHash`). + +Real leaf preimage and its hash, for the account of §5.1 (code_size 6, balance 0x3e8): + +``` +00 0012b9c2d7398802bddf3d70e0e8cf9074f4819101be174b883975a79061d53e7a 00 + 0000000000000006 0000000000000003 000000000000000000000000000003e8 +-> 5dbe9906fc51df4ac846a8fe44ed92a3ec310d2edaeeea605289a290f6b38eba +``` + +Real branch preimage, 5-bit prefix, both children being the leaves above: + +``` +01 0005 00 + 5dbe9906fc51df4ac846a8fe44ed92a3ec310d2edaeeea605289a290f6b38eba + 970021c05f854ea9f1b9dd97d180ae62d0d2b9bb4acc23869cc5879919434ef8 +-> de50844a66c2a773d715492d679fee88416467c0c5a7802a6b033199b357b0a4 +``` + +`de5084…` is the byte string stored as cell 0's hash in the 265-bit record dumped in §5. + +## 5. The branch record + +### 5.1 The example tree + +One account (address `0102…14`, nonce 3, balance 1000, code `60aabb000102`), storage slot 5 holding +5, storage slot 300 holding 0x2c. Four branch records plus the root record. +`stem = 12b9c2d7…61d53e7a`, `codeHash = 1d6423ed…7696574d`. + +Those values are the corpus, not something the records carry: a record names a leaf's identity only +(§6), so every hash below and in §12 needs them supplied from outside. The tree's five leaves in +full: BASIC_DATA packing nonce 3 / balance 1000 / code_size 6; CODE_HASH = +`keccak256(60aabb000102) = 1d6423ed…7696574d`; code chunk 0 in the code zone, that code padded to +31 bytes; slot 5 = `0000…0005`; slot 300 = `0000…002c` (44 — the value, not the slot number). + +The account holds no DELEGATION leaf: its code is contract code, so it takes the CODE_HASH branch +of the exclusive pair (§8). + +Each record below is named by its domain key, with the parent cell it hangs off: + +``` + key 08 root cell: branch, 0-bit prefix, hash = state root + its node is the record at key 00 + + key 00 [ 0 bits] splits on bit 0 (the zone byte's top bit) + |-- bit0 branch, 6-bit prefix ---------------------> node at 7 bits = key 0007 + `-- bit1 leaf, 527-bit prefix, storageAddr --------> slot 300 (528-bit key, zone 0xFF) + + key 0007 [ 7 bits] splits on bit 7, the zone byte's last: + account zone 0x00 against code zone 0x01 + child of key 00, cell bit 0 + |-- bit0 branch, 257-bit prefix ---------------------> node at 265 bits = key 0012b9…7a0001 + `-- bit1 leaf, 264-bit prefix, leafValue ----------> code chunk 0 (zone 0x01, 272-bit key) + + key 0012b9…7a0001 [265 bits] = 0x00 || stem || sub-index bit 264, which is 0 for every + allocated sub-index; splits on sub-index bit 265 + child of key 0007, cell bit 0 + |-- bit0 branch, 5-bit prefix -----------------------> node at 271 bits = key 0012b9…7a0007 + `-- bit1 leaf, 6-bit prefix 000101, storageAddr ---> slot 5 (sub 0x45 = 64+5) + + key 0012b9…7a0007 [271 bits] splits on the sub-index's last bit + child of key 0012b9…7a0001, cell bit 0 + |-- bit0 leaf, 0-bit prefix, accountAddr ------------> BASIC_DATA (sub 0x00) + `-- bit1 leaf, 0-bit prefix, accountAddr ------------> CODE_HASH (sub 0x01) +``` + +Every chain descends through cell bit 0; the bit-1 cells are all leaves. + +The chunk leaf hanging off the zone byte rather than off the account's stem is what content +addressing looks like in the tree: the account's three header keys and its code share nothing below +bit 7. + +Depth arithmetic closes at every step: `record bits + 1 branch bit + cell prefix bits = child's +absolute depth`. `0+1+6 = 7`, `7+1+257 = 265`, `265+1+5 = 271`, `271+1+0 = 272` (the 34-byte account +key), `7+1+264 = 272` (the 34-byte code key), `0+1+527 = 528` (the 66-byte storage key). + +### 5.2 Layout + +``` ++=====================+ written by encode, +| touchMap u16 BE | read by pbinDecodeBranch +| afterMap u16 BE | ++=====================+ +| cell body for bit 0 | present iff afterMap & 1 +| cell body for bit 1 | present iff afterMap & 2 ++=====================+ +``` + +Cells are emitted in ascending bit order (`bitset & -bitset` / `TrailingZeros16`, +`encode`, `pbin_branch.go`); the decoder mirrors it exactly (`pbinDecodeBranch`). + +``` +cell body pbinAppendCell / pbinDecodeCell + fields 1 byte bitmask, below + bitLen uvarint prefix length in BITS, 0..528 + prefix ceil(bitLen/8) bytes, MSB-first, pad bits zero + [accAddr] uvarint(20)=0x14 || 20 bytes + [stoAddr] uvarint(52)=0x34 || 52 bytes + [value] uvarint(32)=0x20 || 32 bytes + [hash] uvarint(32)=0x20 || 32 bytes +``` + +`fields` (`pbinCellFields`, `pbin_branch.go`): bit0 LEAF, bit1 BRANCH, bit2 ACCOUNT_ADDR, +bit3 STORAGE_ADDR, bit4 HASH, bit5 LEAF_VALUE. The optional blocks appear in one fixed order in +both encoder and decoder — accAddr, stoAddr, LEAF_VALUE, HASH (`pbinAppendCell` / +`pbinDecodeCell`) — and that is +**not** the bit order: LEAF_VALUE is bit 5 and HASH is bit 4, so LEAF_VALUE is written first. The +fields byte says which blocks are present, not what order to read them in; a decoder that walks it +LSB-to-MSB takes HASH before LEAF_VALUE and desynchronises the cursor on any cell carrying both +(the §5.5 format-ceiling row). The length prefixes are uvarints but `pbinDecodeFixedVal` +demands the one exact width per field, making `0x14` / `0x34` / `0x20` the only legal +tag bytes. + +The cell prefix is relative to the record's own key plus the branch bit: the record's key is +`pbinAppendBitPath(currentKey)`, the child sits at `keyBits+1`, and `prefix` carries the remainder +down to the child node. + +### 5.3 A real record, byte by byte + +The 265-bit record from §5.1 — a branch child and a header-storage leaf. + +``` +key 0012b9c2d7398802bddf3d70e0e8cf9074f4819101be174b883975a79061d53e7a0001 + +00000000 00 03 00 03 12 05 00 20 de 50 84 4a 66 c2 a7 73 |....... .P.Jf..s| +00000010 d7 15 49 2d 67 9f ee 88 41 64 67 c0 c5 a7 80 2a |..I-g...Adg....*| +00000020 6b 03 31 99 b3 57 b0 a4 09 06 14 34 01 02 03 04 |k.1..W.....4....| +00000030 05 06 07 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13 14 |................| +00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| +00000050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 |................| + +[00..01] 0003 touchMap = 0b11 +[02..03] 0003 afterMap = 0b11 +cell bit 0 +[04] 12 fields = 00010010 BRANCH | HASH +[05] 05 bitLen = uvarint 5 +[06] 00 prefix = 00000 + 3 zero pad bits +[07..27] 20 || hash = de50844a66c2a773d715492d679fee88416467c0c5a7802a6b033199b357b0a4 +cell bit 1 +[28] 09 fields = 00001001 LEAF | STORAGE_ADDR +[29] 06 bitLen = uvarint 6 +[2a] 14 prefix = 000101 + 2 zero pad bits +[2b..5f] 34 || stoAddr = 0102030405060708090a0b0c0d0e0f1011121314 + 0000…0005 (addr || slot, 52 bytes) +``` + +Sub-index reconstruction for cell 1: the record sits at 265 bits, so the sub-index's top bit is +already fixed to `0` by the prefix above it and this record's branch bit supplies the next, `1`. +The cell prefix then supplies `000101`. Full sub-index `0b01000101 = 0x45 = 64 + 5` — storage slot 5 +in the account header (§9). + +The other three records of the same tree: + +``` +key 00 [0 bits] 162 bytes + 0003 0003 + 12 06 00 20 c9aca54ec7a6c2fe06fc1cee22bd609b559f1c16217ce2ea48793349b8d61be5 + 09 8f04 fe257385ae7310057bbe7ae1c1d19f20e9e90322037c2e971072eb4f20c3aa7cf4 + 3211d8496e2c633f71a67a015a0551623e46676cc65d3acc04301137a5fc5a8458 + 34 0102030405060708090a0b0c0d0e0f1011121314 + 000000000000000000000000000000000000000000000000000000000000012c + +key 0007 [7 bits] 142 bytes + 0003 0003 + 12 8102 12b9c2d7398802bddf3d70e0e8cf9074f4819101be174b883975a79061d53e7a00 + 20 ac8c75fc4b6f6e25d0831229dd10d3ad353c56dc573141f6f7e62707a5076b5d + 21 8802 073be86901ad75392dc6c8cd03071cf8e0c17da59c33a1911c7b85c09f969b5a00 + 20 0060aabb00010200000000000000000000000000000000000000000000000000 + +key 0012b9…7a0007 [271 bits] 50 bytes + 0003 0003 + 05 00 14 0102030405060708090a0b0c0d0e0f1011121314 + 05 00 14 0102030405060708090a0b0c0d0e0f1011121314 +``` + +The 7-bit record shows the two zones side by side and needs no shift to read: seven bits are +consumed above it and one more by its own branch, so both cell prefixes start on a byte boundary — +cell 0 carries `stem || 0x00` (the account key from byte 1 on), cell 1 the chunk key's own 33 bytes. +The top record is where the shift shows: the storage key starts `ff 12 b9…` and its cell prefix +(bits 1..527) starts `fe 25 73…`, since shifting left by one turns `ff 12 b9` into `fe 25 73` +(`0xff<<1 | 0x12>>7 = 0xfe`). + +The 271-bit record is the account pair: two leaf cells, zero-bit prefixes, both naming the *same* +20-byte plain key. Which leaf each is is decided by the last bit of the reconstructed tree key and +resolved at hash time by `pbinLeafValue` (`pbin_hash.go`), not by anything in the record. + +### 5.4 touchMap and afterMap + +Both are `uint16` at offsets 0 and 2 (`encode` and `pbinDecodeBranch`, `pbin_branch.go`) purely so +the `OnesCount16` / `TrailingZeros16` arithmetic ports from the hex engine unchanged (`pbinGrid`'s +doc comment, `pbin_cell.go`). Only bits 0 and 1 may be set; `pbinCheckCellMaps` rejects anything +outside `pbinCellBits = 0b11` on both encode and decode. + +`afterMap` is structural — it says which cell bodies follow. `touchMap` is write-time bookkeeping +only. The reader throws it away (`_, afterMap, err := pbinDecodeBranch`, +in `unfoldBranchNode`; the only other call site is `materializeBranch`), and +nothing downstream parses the record either: `TrieContext.PutBranch` hands the bytes straight to +`DomainPut` (`commitmentdb/commitment_context.go`). There is no `BranchData` merge. + +On disk, `afterMap` of a branch record is always `0b11`: `foldBranch` refuses a row that does not +keep exactly two cells (`foldBranch`). A row collapsing to one survivor writes +no record at all — the node moves up and the consumed bits are prepended to the survivor's prefix +(`foldPropagate`); a row keeping nothing writes a zero-length value, which is the deletion +encoding (`foldDelete`). `touchMap` does vary: bits are set at update time (`updateCell`) and +carried upward by `propagateTouch`. + +Both non-branch outcomes are reachable: + +- **One survivor** is routine, and has nothing to do with removal. An unfold that descends into a + cell seeds the new row with that one cell (`unfold`), so a row that no later update splits folds + straight back through `foldPropagate` — the exact inverse of the unfold that opened it. +- **No survivor** needs a parent cell that was touched and is now absent, which `unfoldBranchNode` + loads as `after = 0` through its `deleted` flag. A write of 32 zero bytes is a + deletion (§11), so zeroing a subtree's last leaf reaches it; pinned at + `TestPBinFoldDeleteRunsOnProcess` (`pbin_zerovalue_test.go`). + +A reader still needs an answer for a zero-length value, and it differs by key: at a bit-path key +`unfoldBranchNode` rejects it as a missing branch, so it is not a shape a decoder has to parse; at +the root key `0x08` it is legal and means the empty tree (`loadRoot`). + +That every record carries both children is what removes the merge path +(`pbinBranchEncoder`'s doc comment, `pbin_branch.go`): at arity 2 the untouched sibling is the +whole other half of the subtree, so a record read back replaces its predecessor outright. + +### 5.5 Size + +Per cell: `1 (fields) + 1..2 (bitLen uvarint) + 0..66 (packed prefix) + one value block`. Value +blocks are 21 (account), 53 (storage), 33 (verbatim value), 33 (hash). + +| shape | bytes | reachable | +|---|---:|---| +| `afterMap = 0` | 4 | decodes; `foldBranch` never writes it | +| one bare BRANCH cell `000100010200` | 6 | same | +| two bare BRANCH cells `0003000302000200` | 8 | same | +| two hashed branch cells | 74 | yes | +| **writer floor** — two 0-prefix account leaves | **50** | yes, once in §5.1 (the 271-bit record) | +| **writer ceiling** — two 527-bit-prefix storage leaves | **248** | only at a depth-0 record | +| **format ceiling** — the same plus a HASH block on each | **314** | decodes; writer never emits it | + +All seven rows encode-and-decode round-trip. Measured record sizes for the §5.1 corpus: 162, 142, +96, 50, plus a 35-byte root record. The root record is framed differently and sized in §7. + +Size is driven, in order of weight, by: the two prefix bit lengths (up to 66 bytes each — all the +variance lives here, and it is inverse to depth); which value each child names (53 > 33 > 21); and +the 1-vs-2-byte `bitLen` uvarint at the 128-bit boundary. + +### 5.6 Decoding + +`pbinDecodeBranch(data, cells *[2]pbinCell)` (`pbin_branch.go`) resets both cells +unconditionally, requires ≥4 bytes, reads the maps, re-checks them, then walks +`afterMap` in ascending bit order filling `cells[TrailingZeros16(bit)]`. A cell whose bit is clear +stays zeroed — that is how an absent child is spelled. Any leftover byte is an error. + +Each body restores kind from the LEAF/BRANCH bits; the prefix from the explicit bit count, never +from the byte length, with pad bits asserted zero (`pbinDecodePrefix`); `accountAddrLen` / +`storageAddrLen` / `hashLen` as side effects of their fields being present; and a LEAF_VALUE as +`Update{Flags: StorageUpdate, StorageLen: 32}`. + +Rejections, each observed firing: + +``` +pbinDecodeCell unknown field bits; neither or both node kinds; a leaf naming + 0 or 2+ value sources; a branch carrying a leaf value +pbinDecodePrefix a prefix over 528 bits; non-zero pad bits +pbinDecodeFixedVal a wrong length tag +pbinDecodeBranch trailing bytes + + leaf with both addrs -> malformed branch record: leaf cell fields 00001101 name no single value source + kind = leaf|branch -> malformed branch record: cell fields 00000111 name no single node kind + dirty pad bits -> malformed branch record: non-zero pad bits after a 3-bit prefix + trailing byte -> malformed branch record: 1 trailing bytes +``` + +One asymmetry against the "one canonical form" claim in `pbinDecodeBranch`'s doc comment: a BRANCH +cell carrying ACCOUNT_ADDR or STORAGE_ADDR decodes cleanly (only LEAF_VALUE is refused for +branches). The writer cannot produce it — `foldBranch` resets the upCell before setting kind — so +it is an unreachable spelling the decoder still accepts, +not a live bug. + +Caller side: `unfoldBranchNode` keeps `afterMap` and discards the record's `touchMap`, setting +`touch=0, after=afterMap` normally, or `touch=afterMap, after=0` when the parent cell was touched +and is now gone, which is how a whole subtree is dropped (`unfoldBranchNode`). + +## 6. Leaf cells in a record + +Yes — a leaf child is stored as a full cell body, not as a hash. What it carries is its *identity*, +never its state value, from exactly one of three sources (the decoder enforces exactly one, +`pbinDecodeCell`, `pbin_branch.go`). This section describes records **written by the fold**; the +witness context spells the same three fields differently, below. + +- **ACCOUNT_ADDR** — the 20-byte plain key, set from an update with `len(plainKey)==20` + (`updateCell`, `pbin_patricia_hashed.go`). +- **STORAGE_ADDR** — `addr||slot`, `len(plainKey)==52`. +- **LEAF_VALUE** — 32 raw bytes, and only when the leaf has no plain key at all: the encoder sets it + iff no address field is present (`pbinAppendCell`). That is the code chunk, the + EIP-7702 delegation indicator and any reserved sub-index — every leaf whose value no state domain + holds as a field (`pbinFieldLeafValue`, `pbin_branch.go`; `pbinRecordLeafValue`, `pbin_code.go`). + +A leaf's own hash is **not** in the record. `hashRowCell` writes a computed hash back only for +branch cells (`pbin_patricia_hashed.go`), and no other site sets `hashLen` on a leaf, so the +encoder's HASH field is never emitted for one in practice. The consequence is that rehashing a +decoded record's leaf child requires state-domain reads — `loadCellState` +(`pbin_patricia_hashed.go`) fetches the account or slot behind the plain key. Only branch +children are hash-only. + +Balance, nonce, code hash and storage value are absent for address-bearing leaves: the plain key is +the pointer back into the state domains. + +**Witness-produced records read differently.** A witness has no state domains behind it, so +`fillLeafCell` (`pbin_witness_context.go`) picks the field by re-encoding, not by zone: any +leaf whose 32 bytes round-trip through `pbinLeafValue` verbatim — storage slots, header slots, code +chunks — is written as LEAF_VALUE, and the rest — BASIC_DATA and CODE_HASH, which are +packed from account fields — go into ACCOUNT_ADDR as a synthetic 20-byte *handle*, the first 20 +bytes of the node hash, which the context resolves back to the account state. So on a +witness record ACCOUNT_ADDR is not an address and LEAF_VALUE is not evidence of a code chunk. +Consumers must know which producer wrote the record. + +**A witness pass is told what the parent state cannot say.** It walks the parent state, where a +contract the block deploys has no code and an account the block removed is indistinguishable from +one it created. Both decide which keys the pass has to walk, so the caller supplies them in a +`PBinWitnessBlock` (`pbin_witness.go`) keyed by account plain key: `chunkSource` +(`pbin_update_stream.go`) derives chunk keys from the supplied code — key derivation only, values +stay pre-state — and `removesAccount` reads the supplied removal set instead of the update's delete +flag. Both overrides apply on a witness pass only; a fold ignores a block left set. `SetWitnessBlock` +must be called before the capture, `Witnesses` clears it on return, and +`SharedDomainsCommitmentContext.SetWitnessBlock` (`commitment_context.go`) is a silent no-op on a +hex trie. A bin capture that skips it walks too few keys and prunes away nodes the verifier needs. + +**A witness carries the sibling of every branch its keys descend, not just the proof paths.** A +branch commits to both children, so a hash is enough to *verify* a path — but not to *change* one. +Removing a key collapses the branch above it and moves the surviving sibling up under a longer +prefix, and `H(0x01 || encode_bit_prefix(prefix) || left || right)` binds the prefix the sibling had, +so re-hashing it needs its own children. The capture therefore reads back any branch cell that +arrived as a bare hash (`captureBranchPreimage`, `pbin_patricia_hashed.go`) and the pruner keeps it +(`keepSibling`, `pbin_witness_prune.go`). This is what replaces hex's collapse-sibling detection +phase; the cost is roughly one extra node per level of each proved path. + +## 7. The root record + +`pbinRootKey = {0x08}` (`pbin_patricia_hashed.go`) holds a **bare cell body with no 4-byte +header**: `storeRoot` calls `pbinAppendCell` directly and `loadRoot` calls `pbinDecodeCell` at +position 0, rejecting trailing bytes. A zero-length value at that key is the deletion encoding for +an emptied tree (`storeRoot`). + +``` +key 08, 35 bytes — the §5.1 tree: + 12 00 20 658b62aba5ac2933e86f1100cce5084bdb35b32d797b05d247abf27a2018c064 + ^ BRANCH|HASH + ^ bitLen 0 + ^ len 32 || the state root +``` + +That is the common shape, not the only one. `storeRoot` serialises whatever the root cell is, +and a one-key tree's root is the leaf itself with no branch wrapping it (§4), so the +record can equally be a LEAF cell — with a full-length prefix, since no descent sits above it to +consume any of the key. Measured, the §5.1 address holding slot 300 and nothing else: + +``` +key 08, 122 bytes: + 09 9004 ff12b9…d2fe2d42 2c 34 0102…1314 0000…012c + ^ LEAF|STORAGE_ADDR + ^ uvarint 528 bits + ^ the whole 66-byte tree key, packed + ^ len 52 || addr || slot +``` + +Sizes follow the §5.5 per-cell arithmetic with no 4-byte header: `1 (fields) + 1..2 (bitLen uvarint) ++ 0..66 (packed prefix) + one value block of 21 / 33 / 53`. That is 35 bytes for the branch-and-hash +spelling above, 58 / 70 / 90 for a 272-bit leaf root naming an address, a verbatim value or an +`addr||slot`, and 122 at most — the 528-bit storage leaf shown. A decoder that assumes BRANCH|HASH +fails on every one-key tree. + +The root cell needs a key of its own, and not because nothing names it — the empty path does. The +problem is that the empty path already encodes to the 1-byte key `00`, which is the record of the +top branch node (see §5.1, where `00` and `08` are two different records of the same tree). Every +other node is found by the path that reaches it, so only the root is left needing a key nothing else +claims (`pbinRootKey`'s doc comment). The zero-length key is no alternative either: domain +iteration reads it as end-of-stream and it sorts first, truncating the table, so the datadir would +read back as fresh — pinned against the real `TblCommitmentVals` by +`TestPBinRootRecordRealTableIteration` (`pbin_rootkey_test.go`). + +`0x08` works because the trailing byte of every path key is `bitLen mod 8`, so its range is exactly +`0..7` and `pbinDecodeBitPath` rejects anything above (`pbinDecodeBitPath`). `0x08` is the +smallest byte that can never be a trailing bit count, so a 1-byte key of `0x08` cannot be any +encoded path — checked exhaustively over every `bitLen` 0..528 by +`TestPBinRootKeySentinelNotABitPath`. The same bound keeps `KeyCommitmentState` (`"state"`, tail +`0x65`) out of the path image (`TestPBinBitPathNeverEncodesToStateKey`, `pbin_bitpath_test.go`): + +``` +pbinDecodeBitPath(08) -> pbin: invalid trailing bit count 8 in bit-path key +pbinDecodeBitPath(7374617465) -> pbin: invalid trailing bit count 101 in bit-path key +``` + +The witness-side `PatriciaContext` uses the same two record framings — a bare cell for the root +(`rootRecord`, `pbin_witness_context.go`), a full header plus two cells for a branch, with +`touchMap` set equal to `afterMap` because a read discards it anyway (`branchRecord`). The framing +is shared; what goes into a leaf cell is not — see the witness paragraph in §6. + +## 8. The account header stem + +Zone `0x00`, 34 bytes, `treePosition = stem = H(addr32)`. The trailing byte is the sub-index, and it +partitions a 256-wide subtree under one stem: + +``` +byte: 0 1 ............................ 32 33 + +----+------------------------------------+------+ + | 00 | stem = H(addr32) | sub | + +----+------------------------------------+------+ + +sub 0 BASIC_DATA packed from account state + 1 CODE_HASH 32 raw bytes + 2 DELEGATION the 23-byte indicator, right-padded with nine zeros + 3 .. 63 reserved not packed; leaf carries 32 verbatim bytes + (pbinLeafValue -> pbinRecordLeafValue) + 64 .. 127 storage slots 0..63, value left-padded + 128 .. 255 unallocated no key this embedding derives lands here +``` + +Constants in `pbin_keys.go`; the dispatch that turns a sub-index into a leaf value is +`pbinLeafValue` (`pbin_hash.go`). + +Sub-indices 128..255 held the first 128 code chunks before every chunk moved into the code zone +(§10). They are now reserved like 3..63, and the dispatch treats both ranges the same: a leaf there +carries its 32 bytes verbatim rather than being packed from state, which is the right answer for a +sub-index whose meaning is not yet defined. + +BASIC_DATA packing (`pbinEncodeBasicData` and its offset constants, `pbin_values.go`), +big-endian throughout: + +``` +off: 0 1 2 3 4 8 16 32 + +----+-----------+-----------+----------------+-----------------------+ + |ver | reserved | code_size | nonce | balance (128 bit) | + | 0 | 0 0 0 | u32 | u64 | 16 bytes | + +----+-----------+-----------+----------------+-----------------------+ +``` + +Bytes 0..3 are never written; the zero value of the array supplies them. A balance over 128 bits or +a code size over 2^32-1 is an error, not a truncation — a silent truncation would commit a wrong +root (`pbinEncodeBasicData`). + +The CODE_HASH leaf is the raw 32-byte hash, with the zero hash mapped to `keccak256("")` for a +codeless account (`pbinCodeHashValue`). The DELEGATION leaf holds an EIP-7702 indicator — the 23 +bytes `0xef0100 || target` — right-padded with nine zeros (`pbinEncodeDelegation`, +`pbin_values.go`). That is *not* the chunk encoding of §10: an indicator never executes, so +byte 0 carries code rather than a PUSHDATA count. + +**An existing account holds exactly one of the two**, decided by its code bytes alone +(`pbinIsDelegation`, `pbin_values.go`) and never by its hash — a contract whose *hash* opens +`0xef0100` is still contract code. So a write emits one of the pair and deletes the other +unconditionally, since the stream is told nothing about what the account held a moment ago +(`emitCodeLeaves`, `pbin_update_stream.go`). A delegated account holds no code-zone chunks +at all: its leaf *is* its code, a read takes the leading `code_size` bytes and `EXTCODEHASH` hashes +them. Clearing a delegation restores a CODE_HASH leaf of `keccak256("")` with `code_size` zeroed. + +Neither sibling has a key derivation of its own: `treeKey` (`pbin_keys.go`) only ever +derives BASIC_DATA for an address, and the stream produces the sibling by overwriting the last key +byte inside the same visit (`emitSibling`, `pbin_update_stream.go`). + +Because the delegation leaf also marks an account present, a reader asking whether an account +exists must accept **either** sibling. BASIC_DATA is not that marker: an account with zero nonce, +zero balance and no code stores none (`PBinWitnessState.Account`, `pbin_witness_state.go`). + +``` +addr = 0102030405060708090a0b0c0d0e0f1011121314 +addr32 = 0000000000000000000000000102030405060708090a0b0c0d0e0f1011121314 +stem = 12b9c2d7398802bddf3d70e0e8cf9074f4819101be174b883975a79061d53e7a + +BASIC_DATA key 00 12b9…3e7a 00 +CODE_HASH key 00 12b9…3e7a 01 +DELEGATION key 00 12b9…3e7a 02 + +BASIC_DATA value, nonce=3 balance=1e18 code_size=100: + 00000000 00000064 0000000000000003 00000000000000000de0b6b3a7640000 + ^ver+rsv ^size ^nonce ^balance +CODE_HASH value — a *separate* example, for a codeless account (code_size 0), where the zero +hash maps to keccak256(""): + c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 +DELEGATION value — a third account, delegating to 00…aa, so code_size is 23: + ef0100 00000000000000000000000000000000000000aa 000000000000000000 + ^marker ^target (20 B) ^nine zero bytes +``` + +The three value lines are three different accounts. Pairing the first two would describe an account +running 100 code bytes whose code hashes empty, which no state can produce and the witness rejects +outright — `codeFromLeaves` re-checks the reassembled code against CODE_HASH +(`pbin_witness_state.go`). For one consistent account, see §5.1: code_size 6, CODE_HASH +`1d6423ed…7696574d`. + +## 9. The storage sub-trie + +`pbinSlotInHeader` (`pbin_keys.go`) decides: slot bytes `[0:31]` all zero **and** +`slot[31] < HEADER_STORAGE_SLOTS = 64`. So slots 0..63 only. The spec's invariant is +`HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS <= STEM_SUBTREE_WIDTH`, which pins the header slots +to sub-indices 64..127. + +Header slots take an account-zone key at sub-index `64 + slot` (`storageKey`, `pbin_keys.go`) — same +34-byte shape, same stem, no extra hash. Everything else goes to zone `0xFF`, 66 bytes +(`storageKey`): + +``` +byte: 0 1 .................. 32 33 ....................... 64 65 + +----+-----------------------------+----------------------------+------+ + | FF | stem = H(addr32) | group = H(addr32||treeIdx) | sub | + +----+-----------------------------+----------------------------+------+ + +treeIdx = slot >> 8, as a 32-byte big-endian value = 0x00 || slot[0:31] +sub = slot & 0xFF = slot[31] + +a group = the 256 consecutive slots sharing one treeIdx: + + slot 300 ff | 12b9…3e7a | 1908ec24…d2fe2d42 | 2c \ + slot 301 ff | 12b9…3e7a | 1908ec24…d2fe2d42 | 2d | identical 65-byte prefix + … | -> one dense subtree + slot 319 ff | 12b9…3e7a | 1908ec24…d2fe2d42 | 3f / +``` + +The group preimage is built by `groupDigest` (`pbin_keys.go`) as +`addr32 || 0x00 || slot[0:31]`, 64 bytes. Co-location of a group in one subtree is the point of the +layout, and the digest is memoized per group. + +The key carries **both** digests, and the stem digest is the same one the account leaves use, so a +storage key costs one extra hash over an account key, not two. + +Sharpest discontinuity in the embedding — slot 63 is a 34-byte key inside the account's own header, +slot 64 is a 66-byte key in a different zone (pinned by `TestPBinStorageLayoutCost`, +`pbin_storage_layout_test.go`): + +``` +slot 5 (34) 00 12b9…3e7a 45 +slot 63 (34) 00 12b9…3e7a 7f +slot 64 (66) ff 12b9…3e7a 1fef389e506c6134e0d9befd0702f549c08b2aeeba1bdf45776999fc988076f4 40 +slot 300 (66) ff 12b9…3e7a 1908ec24b716319fb8d33d00ad02a8b11f2333b6632e9d660218089bd2fe2d42 2c + +group preimage for slot 300: + 0000…0102030405060708090a0b0c0d0e0f1011121314 00 00000000000000000000000000000000000000000000000000000000000001 + -> 1908ec24b716319fb8d33d00ad02a8b11f2333b6632e9d660218089bd2fe2d42 +``` + +## 10. The code sub-trie + +A chunk value is 32 bytes: byte 0 is metadata, bytes 1..31 are code +(`pbinChunkDataLen = 31`, `pbin_code.go`). + +``` ++----+---------------------------------+ +| n | 31 bytes of bytecode | ++----+---------------------------------+ + ^ leading bytes of this chunk that are PUSHDATA, clamped to 31 +``` + +The code is zero-padded to a multiple of 31 **before** the PUSHDATA scan +(`pbinChunkifyCode`, `pbin_code.go`). That ordering buys two things: the last chunk is always a +full 32 bytes with a zero tail, and a PUSH whose data runs off the end of the real code counts +against the padded tail instead of being dropped. `pushdataAt[i]` is how many bytes from `i` on are +still PUSHDATA; the table is allocated a whole chunk past the padded code so a PUSH32 on the last +byte has room, and `chunk[0] = min(pushdataAt[pos], 31)`. A PUSH is any opcode in `[0x60, 0x7f]` +(`pbinPush1` .. `pbinPush32`). The scan runs over the whole code, so residual PUSHDATA carries +across chunk boundaries — which is exactly what the byte reports. + +``` +code (40 bytes), PUSH2 at offset 0 and PUSH32 at offset 30 so its data crosses the boundary: + 61aabb 000000000000000000000000000000000000000000000000000000 7f f0f1f2f3f4f5f6f7f8 + ^ 27 zero bytes, offsets 3..29 + +chunk 0 = 00 61aabb0000000000000000000000000000000000000000000000000000007f + ^^ offset 0 is an opcode +chunk 1 = 1f f0f1f2f3f4f5f6f7f800000000000000000000000000000000000000000000 + ^^ 31: every byte of this chunk is PUSH32 data, clamped from 32; + the tail is padding added before the scan + +short code 000102 -> one chunk: 00 000102 0000…00 +empty code -> zero chunks (pbinChunkifyCode) +``` + +**Every** chunk lives in the code zone; the account header holds none. One deriver takes a code hash +and a chunk id (`codeChunkKey`, `pbin_keys.go`): + +``` +treeIndex = chunkID / 256 the chunk's code group +preimage (64 B) = codeHash(32) || 24 zero bytes || u64BE(treeIndex) +key (34 B) = 0x01 || H(preimage) || byte(chunkID % 256) +``` + +An aligned run of 256 chunks sharing one `treeIndex` is a code group: its chunks share a stem and +differ only in the sub-index byte, so a contract of at most 256 chunks (7936 bytes) occupies one +dense subtree and the group edge is the only boundary in the layout. + +The derivation names no address — only the code hash. Two accounts running the same bytecode derive +identical keys and share one set of leaves, whatever the code's size +(`pbinTreeKeyCodeChunk`, `pbin_keys.go`). The dedup is realised at emit time: chunks are +buffered, sorted by key, and duplicate keys collapse to one emission, with an error if two carry +different values (`flushCodeChunks`, `pbin_update_stream.go`). The chunk digest is +deliberately not memoized: the digest cache's entries are bound to an address these keys do not +have (`codeChunkKey`'s doc comment). + +``` +codeHash = 7b1e263ffcf71ebd01a2edd752b53eb24ed6abf042e8678a4a1db8d05d5d31b0 + chunk 0 (group 0) 01 1b05bf4b082e83c2b306efdbfdd460ba5193adeebcec3ca8453a5cff437d3f4d 00 + chunk 255 (group 0) 01 1b05bf4b082e83c2b306efdbfdd460ba5193adeebcec3ca8453a5cff437d3f4d ff + chunk 256 (group 1) 01 2aeb430d323776088db507c7efbad5c4797d0f748b8b8a0112153cb665a413f5 00 + chunk 512 (group 2) 01 aa179620390ea03ed4cd924bbb94938f8162bf6741205ed18fbd5a34e449b9c0 00 + ^ each group is a fresh stem; no address in any of them +``` + +A chunk of 32 zero bytes is stored as no leaf at all, like any other zero value (§11). That takes +31 zero code bytes **and** a zero PUSHDATA count in byte 0 — zero bytes continuing PUSHDATA from an +earlier chunk do not qualify, since byte 0 then records the continuation. Chunk presence therefore +does not delimit the code: `code_size` does, and an absent chunk reads back as the zeros it stands +for (`codeFromLeaves`, `pbin_witness_state.go`). + +Chunk leaves carry no plain key: no state domain holds a code chunk, since chunking is a property of +the tree rather than of the account. They are emitted with a nil plain key +(`flushCodeChunks`), validated in `updateCell` (`pbin_patricia_hashed.go`), and stored under +`pbinFieldLeafValue` (§6). Emission ordering keeps the trie walk monotone: chunks are queued as +accounts are visited (`queueChunks`, `pbin_update_stream.go`) and flushed once a key past +the code zone appears (`flushCodeChunksBefore`). + +A delegated account queues nothing: its indicator lives in the header and it owns no chunk leaves +at all (§8). + +Read-back, for a stateless verifier: concatenate `value[1:]` of chunks `0..ceil(size/31)-1`, +truncate to `code_size`, verify against the CODE_HASH leaf (`pbin_witness_state.go`). + +## 11. There is no storage root + +Nothing computes one. The engine doc comment says so (`PBinPatriciaHashed`) and the +witness account type says so (`PBinAccount`) — those two comments are all +`grep -i "storage root" pbin_*.go` finds. The absence of code is a different grep, over the +identifier: `grep -n "storageRoot\|StorageRoot" pbin_*.go` returns nothing at all — no producer, no +consumer. `PBinAccount` is exactly Nonce, Balance, CodeSize, CodeHash +(`pbin_witness_state.go`). BASIC_DATA has no room for one either: +`1 + 3 + 4 + 8 + 16 = 32`, fully accounted (`pbin_values.go`). + +What replaces it is a single flat global trie. Account fields, storage slots and code chunks are all +ordinary leaves of *one* binary trie, each addressed by its own 34- or 66-byte tree key. There is no +nesting, so there is no second trie to have a root. `pbinLeafValue` (`pbin_hash.go`) +enumerates every value a leaf may hold — BASIC_DATA, CODE_HASH, a padded storage word, a verbatim +32-byte record value — and none of them is a subtree hash. + +An account and its storage are related only by sharing a key **prefix**: bytes 1..32 of the +account-zone key and bytes 1..32 of the storage-zone key are the same `H(addr32)` +(`accountHeaderStem` vs `storageKey`, `pbin_keys.go`). Prefix, not containment. The account's code +shares not even that: it is keyed by code hash and sits in a third zone. + +``` +account BASIC_DATA : 00 |12b9c2d7…61d53e7a| 00 +account CODE_HASH : 00 |12b9c2d7…61d53e7a| 01 +storage slot 5 : 00 |12b9c2d7…61d53e7a| 45 +storage slot 300 : ff |12b9c2d7…61d53e7a| 1908ec24…d2fe2d42 2c + ^^^^^^^^^^^^^^^^^^ same stem, different zone +code chunk 0 : 01 |073be869…9f969b5a| 00 + ^^^^^^^^^^^^^^^^^^ H(codeHash || 0), no stem at all +``` + +Compare the hex engine in the same package, where the MPT structure is explicit: +`accountForHashing(buffer, storageRootHash)` writes the 32-byte root into the account RLP +(`hex_patricia_hashed.go`), called from `computeCellHash` and +`witnessComputeCellHashWithStorage`; both derive `storageRootHash` down the fold, the witness one +threading a `storageRootHashIsSet` flag with it, and both give a storage-less account +`empty.RootHash`. The pbin fold has no equivalent variable. + +The near-miss worth naming so it is not mistaken for one: the subtree under the 264-bit prefix +`0xFF || stem` does hold exactly one account's non-header slots, and the cell at that point has a +hash. But nothing references it — no leaf value, no record field, no API — and it excludes slots +0..63, which live in the account zone. + +Behavioural consequences: + +- Proving a slot is a root-to-leaf walk of the global trie, per slot: + `Storage` is `tree.leaf(storageKey(addr, slot))` (`PBinWitnessState.Storage`, + `pbin_witness_state.go`). There is no per-account root to prove first and descend from. +- Deleting an account is deleting two key-space regions, not dropping one node. `removeAccount` + (`pbin_update_stream.go`) emits a drop at the account's header stem and another at its + storage prefix once the walk reaches that zone, because nothing enumerates the slots an account + holds. Its code-zone leaves are content-addressed and stay: another account may run the same + bytecode. This is where the engine parts from `binarize(post_state)` — the reference suite drops + the chunks when the removed account was the sole holder, and the engine keeps them. EIP-6780 + bounds the gap to states the chain cannot reach: on-chain, an account is only deleted with its + code in the transaction that created it, and such an account's chunks were never inserted (a + create-and-destroy merges to a bare deletion). Under the MPT, self-destruct drops one storage + root; here there is no such node. +- Zero and absent are the same state, so a write of 32 zero bytes removes the leaf rather than + storing zeros (`state_write`, eip:"Zero values and deletion"), and the fold collapses whatever + subtree that empties (§5.4). +- A subtree drop resets one cell, and nothing unfolds what was beneath it, so no fold reaches the + branch records stored there. `dropSubtreeRecords` (`pbin_patricia_hashed.go`) walks them from the + dropped cell's record down and deletes each one; without it the commitment domain would keep a + row per internal node of every removed account, which pruning does not collect because it goes by + step rather than by reachability. The rows a *collapsing* fold leaves behind are deleted + separately (`deleteRowRecord`). A decoded witness carries no node the proof paths did not need, + so a drop against one sweeps nothing (`pbinDerivedContext`). +- The one persisted root record, under key `0x08`, is the root cell of the whole trie — one per + trie, never per account. + +## 12. Reconstruction check + +Rebuilding the §5.1 tree by hand from the stored records **plus the leaf values read from the state +domains**, using only §4's two preimage shapes, reproduces the engine's `Process()` root. The +records alone are not enough input: only the code chunk carries its 32 bytes in the record +(LEAF_VALUE, §6), while an address-bearing leaf names a plain key and nothing else, so BASIC_DATA's +fields, CODE_HASH and the two storage values come from outside. Both storage values are elided +below: `0000…0005` is slot 5 holding 5, and `0000…002c` is slot 300 holding 0x2c (44) — not the slot +number 300 = 0x12c, which would give `L_slot300 = 120daed1…` and `root = 78497b28…` instead. + +``` +L_basic = H(0x00 || 0012b9…7a00 || 0000000000000006 0000000000000003 …03e8) + = 5dbe9906fc51df4ac846a8fe44ed92a3ec310d2edaeeea605289a290f6b38eba +L_code = H(0x00 || 0012b9…7a01 || 1d6423ed…7696574d) + = 970021c05f854ea9f1b9dd97d180ae62d0d2b9bb4acc23869cc5879919434ef8 +N271 = H(0x01 || 0005 || 00 || L_basic || L_code) + = de50844a66c2a773d715492d679fee88416467c0c5a7802a6b033199b357b0a4 [265-bit rec, cell 0] +L_slot5 = H(0x00 || 0012b9…7a45 || 0000…0005) + = a3fe2808a326a445d72d6488cf48f5a73fa6e9eb552e7e4b224785b8a0208305 +N265 = H(0x01 || 0101 || 12b9c2d7…61d53e7a 00 || N271 || L_slot5) + = ac8c75fc4b6f6e25d0831229dd10d3ad353c56dc573141f6f7e62707a5076b5d [7-bit rec, cell 0] +L_chunk0 = H(0x00 || 01073be8…9f969b5a 00 || 0060aabb000102 0000…00) + = c2b8ca4b597abfe8f13fa11cebfcf945d417addef7841108b1064abe064c50e0 +N7 = H(0x01 || 0006 || 00 || N265 || L_chunk0) + = c9aca54ec7a6c2fe06fc1cee22bd609b559f1c16217ce2ea48793349b8d61be5 [0-bit rec, cell 0] +L_slot300= H(0x00 || ff12b9…d2fe2d42 2c || 0000…002c) + = 8cfca105b43b269e0b12a1fcd0649a8b193381be942582566dc573ab8749fa49 +root = H(0x01 || 0000 || N7 || L_slot300) + = 658b62aba5ac2933e86f1100cce5084bdb35b32d797b05d247abf27a2018c064 [key 08] +``` + +`0x0101` is `u16(257)` — the branch preimage's fixed-width count, where the same 257-bit prefix is +spelled `8102` as a uvarint inside the record. Every intermediate hash equals the bytes stored in +the corresponding record. + +`L_chunk0` is where content addressing shows in the arithmetic: the chunk's key names the code hash, +not the account, so an identical contract at any other address produces this same leaf and the same +`N7` input. diff --git a/execution/chain/chain_config.go b/execution/chain/chain_config.go index 23b5d2834a4..8bb16d7cfe7 100644 --- a/execution/chain/chain_config.go +++ b/execution/chain/chain_config.go @@ -84,6 +84,11 @@ type Config struct { OsakaTime *uint64 `json:"osakaTime,omitempty"` AmsterdamTime *uint64 `json:"amsterdamTime,omitempty"` + // EIP8038Revised charges EIP-8038's revised state-access schedule instead of the + // one the pinned spec-test corpora were generated against. Experimental forks that + // track head-of-spec set it; no scheduled network does. + EIP8038Revised bool `json:"eip8038Revised,omitempty"` + // Optional EIP-4844 parameters (see also EIP-7691, EIP-7840, EIP-7892) MinBlobGasPrice *uint64 `json:"minBlobGasPrice,omitempty"` BlobSchedule map[string]*params.BlobConfig `json:"blobSchedule,omitempty"` @@ -871,6 +876,7 @@ type Rules struct { IsIstanbul, IsBerlin, IsLondon, IsShanghai bool IsCancun, IsNapoli, IsAhmedabad, IsBhilai bool IsPrague, IsOsaka, IsAmsterdam bool + EIP8038Revised bool DisabledEIPs []int IsAura bool diff --git a/execution/commitment/backtester/pbin_m1a_test.go b/execution/commitment/backtester/pbin_m1a_test.go new file mode 100644 index 00000000000..fe8433844b7 --- /dev/null +++ b/execution/commitment/backtester/pbin_m1a_test.go @@ -0,0 +1,387 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +// These tests drive the bin commitment trie over a real MDBX datadir with no +// external oracle for the roots. The cross-check is determinism: a forward run +// and a rebuild that has only the account and storage domains to work from must +// agree. +package backtester_test + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/c2h5oh/datasize" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/dir" + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/dbcfg" + "github.com/erigontech/erigon/db/kv/mdbx" + "github.com/erigontech/erigon/db/kv/rawdbv3" + "github.com/erigontech/erigon/db/kv/temporal" + "github.com/erigontech/erigon/db/state" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/commitment/commitmentdb" + "github.com/erigontech/erigon/execution/types/accounts" +) + +const ( + pbinM1AStepSize = uint64(8) + pbinM1AAccounts = 6 + pbinM1ASlots = 4 +) + +// Makes PickTrieVariant() resolve to the bin trie. The flag is process-wide, so +// these tests never run in parallel. +func pbinM1ABinVariant(t *testing.T) { + t.Helper() + orig := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = orig }) + statecfg.ExperimentalBinCommitment = true +} + +func pbinM1ANewAgg(t *testing.T, rawDB kv.RwDB, dirs datadir.Dirs, stepSize uint64) *state.Aggregator { + t.Helper() + agg := state.NewTest(dirs).StepSize(stepSize).Logger(log.New()).MustOpen(t.Context(), rawDB) + t.Cleanup(agg.Close) + // Referenced branches rewrite bytes at hex cell offsets during merge. Production + // refuses that combination when resolving settings; NewTest bypasses it. + agg.ForTestReferencesInCommitmentBranches(kv.CommitmentDomain, false) + require.NoError(t, agg.OpenFolder()) + return agg +} + +func pbinM1ANewDatadir(t *testing.T, stepSize uint64) (kv.TemporalRwDB, *state.Aggregator, datadir.Dirs) { + t.Helper() + dirs := datadir.New(t.TempDir()) + rawDB := mdbx.New(dbcfg.ChainDB, log.New()).InMem(t, dirs.Chaindata). + GrowthStep(32 * datasize.MB).MapSize(2 * datasize.GB).MustOpen() + t.Cleanup(rawDB.Close) + + agg := pbinM1ANewAgg(t, rawDB, dirs, stepSize) + db, err := temporal.New(rawDB, agg, nil) + require.NoError(t, err) + t.Cleanup(db.Close) + return db, agg, dirs +} + +// Reopens the aggregator over the same folder — the file-visibility half of a +// node restart. +func pbinM1AReopen(t *testing.T, db kv.TemporalRwDB, agg *state.Aggregator, dirs datadir.Dirs, stepSize uint64) (kv.TemporalRwDB, *state.Aggregator) { + t.Helper() + agg.Close() + newAgg := pbinM1ANewAgg(t, db, dirs, stepSize) + newDB, err := temporal.New(db, newAgg, nil) + require.NoError(t, err) + return newDB, newAgg +} + +func pbinM1AAddr(i int) []byte { + a := make([]byte, length.Addr) + a[0] = 0xa0 + a[1] = byte(i) + a[length.Addr-1] = byte(i*7 + 1) + return a +} + +func pbinM1ASlotKey(addr []byte, j int) []byte { + k := make([]byte, length.Addr+length.Hash) + copy(k, addr) + k[length.Addr] = byte(j) + k[len(k)-1] = byte(j*13 + 3) + return k +} + +// Pins that the bin trie is really in play — a hex fallback would make every +// assertion below vacuous. +func pbinM1ABinSharedDomains(t *testing.T, tx kv.TemporalTx) *execctx.SharedDomains { + t.Helper() + sd, err := execctx.NewSharedDomains(t.Context(), tx, log.New()) + require.NoError(t, err) + require.IsType(t, &commitment.PBinPatriciaHashed{}, sd.GetCommitmentCtx().Trie()) + return sd +} + +// Writes accounts and storage for txNums [fromTx, toTx), saving the commitment +// state at every step boundary. Returns the root at each boundary keyed by the +// boundary txNum, plus the last root. +func pbinM1AForwardRun(t *testing.T, db kv.TemporalRwDB, stepSize, fromTx, toTx uint64) (map[uint64][]byte, []byte) { + t.Helper() + rwTx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer rwTx.Rollback() + + sd := pbinM1ABinSharedDomains(t, rwTx) + defer sd.Close() + + roots := make(map[uint64][]byte) + var last []byte + for txNum := fromTx; txNum < toTx; txNum++ { + for i := range pbinM1AAccounts { + addr := pbinM1AAddr(i) + acc := accounts.Account{ + Nonce: txNum + 1, + Balance: *uint256.NewInt(txNum*1_000 + uint64(i)), + CodeHash: accounts.EmptyCodeHash, + } + prev, _, err := sd.GetLatest(kv.AccountsDomain, rwTx, addr) + require.NoError(t, err) + require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, addr, accounts.SerialiseV3(&acc), txNum, prev)) + + for j := range pbinM1ASlots { + sk := pbinM1ASlotKey(addr, j) + val := []byte{byte(txNum + 1), byte(i + 1), byte(j + 1)} + prev, _, err := sd.GetLatest(kv.StorageDomain, rwTx, sk) + require.NoError(t, err) + require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTx, sk, val, txNum, prev)) + } + } + if (txNum+1)%stepSize == 0 { + last, err = sd.ComputeCommitment(t.Context(), rwTx, true, 0, txNum, "pbin-m1a", nil) + require.NoError(t, err) + require.NotEmpty(t, last) + roots[txNum] = bytes.Clone(last) + } + } + require.NoError(t, sd.Flush(t.Context(), rwTx)) + require.NoError(t, rwTx.Commit()) + return roots, last +} + +// Re-folds the whole tree with every leaf touched, so no leaf value comes from +// a branch record. +func pbinM1ARecomputeRoot(t *testing.T, db kv.TemporalRwDB) []byte { + t.Helper() + rwTx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer rwTx.Rollback() + + sd := pbinM1ABinSharedDomains(t, rwTx) + defer sd.Close() + + for _, d := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { + it, err := rwTx.Debug().RangeLatest(d, nil, nil, -1) + require.NoError(t, err) + for it.HasNext() { + k, _, err := it.Next() + require.NoError(t, err) + sd.GetCommitmentCtx().TouchKey(d, string(k), nil) + } + it.Close() + } + root, err := sd.ComputeCommitment(t.Context(), rwTx, false, 0, 0, "pbin-m1a-recompute", nil) + require.NoError(t, err) + return root +} + +// The root a freshly opened SharedDomains restores from the saved commitment +// state, without folding anything. +func pbinM1ARestoredRoot(t *testing.T, db kv.TemporalRwDB) []byte { + t.Helper() + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + sd := pbinM1ABinSharedDomains(t, tx) + defer sd.Close() + root, err := sd.GetCommitmentCtx().Trie().RootHash() + require.NoError(t, err) + return root +} + +// The first txNum not yet in the account and storage files. Collation always +// leaves the newest step in the db, so a files-only rebuild reproduces the root +// as of this boundary, not the last one the forward run computed. +func pbinM1ACollatedTxNum(t *testing.T, db kv.TemporalRwDB) uint64 { + t.Helper() + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + at := state.AggTx(tx) + accTxNum := at.TxNumsInFiles(kv.AccountsDomain) + require.Equal(t, accTxNum, at.TxNumsInFiles(kv.StorageDomain), + "the rebuild reads both domains at one boundary") + return accTxNum +} + +func pbinM1ABranchRecords(t *testing.T, db kv.TemporalRwDB) map[string][]byte { + t.Helper() + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + out := make(map[string][]byte) + it, err := tx.Debug().RangeLatest(kv.CommitmentDomain, nil, nil, -1) + require.NoError(t, err) + defer it.Close() + for it.HasNext() { + k, v, err := it.Next() + require.NoError(t, err) + if bytes.Equal(k, commitmentdb.KeyCommitmentState) { + continue + } + out[string(k)] = bytes.Clone(v) + } + return out +} + +// Counts branch records gone from the db table, so a latest read of them can +// only come from the collated files. +func pbinM1AFileServedRecords(t *testing.T, db kv.TemporalRwDB, records map[string][]byte) int { + t.Helper() + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + var fromFiles int + for k := range records { + v, err := tx.GetOne(kv.TblCommitmentVals, []byte(k)) + require.NoError(t, err) + if len(v) == 0 { + fromFiles++ + } + } + return fromFiles +} + +// Wipes commitment from the db tables and the snapshot dir, so a rebuild has to +// derive the tree from the account and storage domains alone. +func pbinM1AWipeCommitment(t *testing.T, db kv.TemporalRwDB, agg *state.Aggregator, dirs datadir.Dirs, stepSize uint64) (kv.TemporalRwDB, *state.Aggregator) { + t.Helper() + rwTx, err := db.BeginRw(t.Context()) + require.NoError(t, err) + defer rwTx.Rollback() + tables, err := rwTx.ListTables() + require.NoError(t, err) + commitStr := kv.CommitmentDomain.String() + for _, b := range tables { + if strings.Contains(strings.ToLower(b), commitStr) { + require.NoError(t, rwTx.ClearTable(b)) + } + } + require.NoError(t, rwTx.Commit()) + + // Windows refuses to remove a still-mapped file, so drop the file handles first. + agg.Close() + paths, err := dir.ListFiles(dirs.SnapDomain, ".kv") + require.NoError(t, err) + for _, p := range paths { + if !strings.Contains(p, commitStr) { + continue + } + require.NoError(t, dir.RemoveFile(p)) + base := strings.TrimSuffix(p, ".kv") + for _, ext := range []string{".kvi", ".kvei", ".bt"} { + _ = dir.RemoveFile(base + ext) // accessors may not exist + } + } + + newAgg := pbinM1ANewAgg(t, db, dirs, stepSize) + newDB, err := temporal.New(db, newAgg, nil) + require.NoError(t, err) + return newDB, newAgg +} + +func TestPBinM1AForwardRunMatchesRebuildFromDomains(t *testing.T) { + pbinM1ABinVariant(t) + txCount := 4 * pbinM1AStepSize + + db, agg, dirs := pbinM1ANewDatadir(t, pbinM1AStepSize) + stepRoots, forwardRoot := pbinM1AForwardRun(t, db, pbinM1AStepSize, 0, txCount) + require.NoError(t, agg.BuildFiles(txCount)) + + require.Equal(t, forwardRoot, pbinM1ARecomputeRoot(t, db), + "a full-touch recompute over the same datadir must reproduce the forward root") + + collatedTxNum := pbinM1ACollatedTxNum(t, db) + require.Positive(t, collatedTxNum, "collation must produce account and storage files to rebuild from") + wantRoot := stepRoots[collatedTxNum-1] + require.NotEmpty(t, wantRoot, "the collated boundary must be one the forward run computed a root at") + + db, agg = pbinM1AWipeCommitment(t, db, agg, dirs, pbinM1AStepSize) + require.Empty(t, pbinM1ABranchRecords(t, db), "the wipe must leave no commitment records") + + rebuiltRoot, err := state.RebuildCommitmentFiles(t.Context(), db, &rawdbv3.TxNums, log.New(), false) + require.NoError(t, err) + require.Equal(t, wantRoot, rebuiltRoot, "rebuild-from-domains must reproduce the forward root") + + require.NoError(t, agg.OpenFolder()) + require.NoError(t, agg.BuildMissedAccessors(t.Context(), 1)) + require.Equal(t, wantRoot, pbinM1ARestoredRoot(t, db), + "the rebuilt files must carry a trie state that restores to the rebuilt root") + require.Equal(t, forwardRoot, pbinM1ARecomputeRoot(t, db), + "the rebuilt commitment records must fold back to the forward root") +} + +// The second half touches only its own keys, so the root can only come out right +// if the saved trie state and the persisted branch records both round-trip. +func TestPBinM1ARestartResumesToSameRoot(t *testing.T) { + pbinM1ABinVariant(t) + half := 2 * pbinM1AStepSize + + uninterrupted, _, _ := pbinM1ANewDatadir(t, pbinM1AStepSize) + _, wantRoot := pbinM1AForwardRun(t, uninterrupted, pbinM1AStepSize, 0, 2*half) + + restarted, agg, dirs := pbinM1ANewDatadir(t, pbinM1AStepSize) + _, firstRoot := pbinM1AForwardRun(t, restarted, pbinM1AStepSize, 0, half) + require.NotEqual(t, wantRoot, firstRoot, "the two halves must not write identical state") + + restarted, _ = pbinM1AReopen(t, restarted, agg, dirs, pbinM1AStepSize) + require.Equal(t, firstRoot, pbinM1ARestoredRoot(t, restarted), + "a restart must restore the saved root before folding anything") + + _, resumedRoot := pbinM1AForwardRun(t, restarted, pbinM1AStepSize, half, 2*half) + require.Equal(t, wantRoot, resumedRoot, "a restart mid-run must resume to the uninterrupted root") +} + +func TestPBinM1ABranchRecordsSurviveCollationAndMerge(t *testing.T) { + pbinM1ABinVariant(t) + txCount := 4 * pbinM1AStepSize + + db, agg, dirs := pbinM1ANewDatadir(t, pbinM1AStepSize) + pbinM1AForwardRun(t, db, pbinM1AStepSize, 0, txCount) + + inDB := pbinM1ABranchRecords(t, db) + require.NotEmpty(t, inDB) + require.Zero(t, pbinM1AFileServedRecords(t, db, inDB), "before collation every record lives in the db") + + require.NoError(t, agg.BuildFiles(txCount)) + rwTx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer rwTx.Rollback() + _, err = rwTx.PruneSmallBatches(t.Context(), time.Hour) + require.NoError(t, err) + require.NoError(t, rwTx.Commit()) + require.NoError(t, agg.MergeLoop(t.Context())) + require.Positive(t, pbinM1AFileServedRecords(t, db, inDB), + "pruning must move records out of the db, otherwise the reads below never reach the files") + + require.Equal(t, inDB, pbinM1ABranchRecords(t, db), + "collation and merge must preserve bin branch records byte-for-byte") + + db, _ = pbinM1AReopen(t, db, agg, dirs, pbinM1AStepSize) + require.Equal(t, inDB, pbinM1ABranchRecords(t, db), + "the records must read back identically after a folder reopen") +} diff --git a/execution/commitment/commitment.go b/execution/commitment/commitment.go index 8cb4c460a72..f7141e5c9e2 100644 --- a/execution/commitment/commitment.go +++ b/execution/commitment/commitment.go @@ -116,6 +116,15 @@ type Trie interface { Release() } +// StatefulTrie is the optional capability of a Trie to save its in-memory state +// into the commitment-state record and restore it after a restart. Both calls +// require a fully folded trie; a state blob is engine-specific and must only be +// restored by the variant that produced it. +type StatefulTrie interface { + EncodeCurrentState(buf []byte) ([]byte, error) + SetState(buf []byte) error +} + type CommitProgress struct { KeyIndex uint64 UpdateCount uint64 @@ -142,6 +151,10 @@ const ( VariantHexPatriciaTrie TrieVariant = "hex-patricia-hashed" VariantParallelHexPatricia TrieVariant = "hex-parallel-patricia-hashed" VariantStreamingHexPatricia TrieVariant = "hex-streaming-patricia-hashed" + // VariantBinPatriciaTrie is EIP-8297's binary tree. Experimental: a + // whole-datadir property resolved at first start, sequential only, and + // unsupported on the paths listed in PBinPatriciaHashed's doc. + VariantBinPatriciaTrie TrieVariant = "bin-patricia-hashed" ) // InitializeTrieAndUpdates constructs the trie + updates buffer from cfg. @@ -159,6 +172,12 @@ func InitializeTrieAndUpdates(mode Mode, tmpdir string, cfg TrieConfig) (Trie, * tree := NewUpdates(ModeParallel, tmpdir, KeyToHexNibbleHash) tree.SetStreamingCommitter(sc) return trie, tree + case VariantBinPatriciaTrie: + // ModeDirect regardless of the argument: the parallel prefix trie is a + // hex-nibble structure and the binary key space has no nibbles. + trie := NewPBinPatriciaHashed(nil) + tree := NewUpdates(ModeDirect, tmpdir, trie.setHashSuite(pbinSelectedSum)) + return trie, tree case VariantHexPatriciaTrie: fallthrough default: @@ -1205,6 +1224,8 @@ func ParseTrieVariant(s string) TrieVariant { switch s { case "parallel": trieVariant = VariantParallelHexPatricia + case "bin": + trieVariant = VariantBinPatriciaTrie case "hex": fallthrough default: @@ -1706,6 +1727,7 @@ func (t *Updates) TouchPlainKeyDirect(key string, update *Update) { } if update.Flags&CodeUpdate != 0 { existing.update.CodeHash = update.CodeHash + existing.update.CodeSize = update.CodeSize existing.update.Flags |= CodeUpdate } if update.Flags&StorageUpdate != 0 { @@ -2186,6 +2208,9 @@ type Update struct { Flags UpdateFlags Balance uint256.Int Nonce uint64 + // CodeSize travels with CodeHash and is read only by the binary trie, whose + // BASIC_DATA leaf packs it (eip-8297). + CodeSize uint64 } func (u *Update) Reset() { @@ -2194,6 +2219,7 @@ func (u *Update) Reset() { u.Nonce = 0 u.StorageLen = 0 u.CodeHash = empty.CodeHash + u.CodeSize = 0 } // Copy creates a deep copy of the Update. @@ -2207,6 +2233,7 @@ func (u *Update) Copy() *Update { StorageLen: u.StorageLen, Flags: u.Flags, Nonce: u.Nonce, + CodeSize: u.CodeSize, } c.Balance.Set(&u.Balance) return c @@ -2231,6 +2258,7 @@ func (u *Update) Merge(b *Update) { if b.Flags&CodeUpdate != 0 { u.Flags |= CodeUpdate copy(u.CodeHash[:], b.CodeHash[:]) + u.CodeSize = b.CodeSize } if b.Flags&StorageUpdate != 0 { u.Flags |= StorageUpdate @@ -2251,6 +2279,8 @@ func (u *Update) Encode(buf []byte, numBuf []byte) []byte { } if u.Flags&CodeUpdate != 0 { buf = append(buf, u.CodeHash[:]...) + n := binary.PutUvarint(numBuf, u.CodeSize) + buf = append(buf, numBuf[:n]...) } if u.Flags&StorageUpdate != 0 { n := binary.PutUvarint(numBuf, uint64(u.StorageLen)) @@ -2303,6 +2333,15 @@ func (u *Update) Decode(buf []byte, pos int) (int, error) { } copy(u.CodeHash[:], buf[pos:pos+32]) pos += length.Hash + var n int + u.CodeSize, n = binary.Uvarint(buf[pos:]) + if n == 0 { + return 0, errors.New("decode Update: buffer too small for codeSize") + } + if n < 0 { + return 0, errors.New("decode Update: codeSize overflow") + } + pos += n } if u.Flags&StorageUpdate != 0 { l, n := binary.Uvarint(buf[pos:]) @@ -2313,6 +2352,9 @@ func (u *Update) Decode(buf []byte, pos int) (int, error) { return 0, errors.New("decode Update: storage pos overflow") } pos += n + if l > uint64(len(u.Storage)) { + return 0, errors.New("decode Update: storage len out of range") + } if len(buf) < pos+int(l) { return 0, errors.New("decode Update: buffer too small for storage") } @@ -2336,7 +2378,7 @@ func (u *Update) String() string { sb.WriteString(fmt.Sprintf(", Nonce: [%d]", u.Nonce)) } if u.Flags&CodeUpdate != 0 { - sb.WriteString(fmt.Sprintf(", CodeHash: [%x]", u.CodeHash)) + sb.WriteString(fmt.Sprintf(", CodeHash: [%x], CodeSize: [%d]", u.CodeHash, u.CodeSize)) } if u.Flags&StorageUpdate != 0 { sb.WriteString(fmt.Sprintf(", Storage: [%x]", u.Storage[:u.StorageLen])) diff --git a/execution/commitment/commitment_test.go b/execution/commitment/commitment_test.go index 21818de34ef..00e2bdb814f 100644 --- a/execution/commitment/commitment_test.go +++ b/execution/commitment/commitment_test.go @@ -30,6 +30,7 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" "github.com/erigontech/erigon/db/kv" ) @@ -284,6 +285,23 @@ func TestHashSort_WaitBufferFreeErrorKeepsArenaInvariant(t *testing.T) { }) } +// TestUpdateDecodeRefusesOversizedStorage: the storage length is a varint on the +// wire but an int8 in the struct, so a value past the field's own width has to +// be refused at the bound check rather than wrap negative. +func TestUpdateDecodeRefusesOversizedStorage(t *testing.T) { + t.Parallel() + + for _, storageLen := range []uint64{uint64(length.Hash) + 1, 200} { + buf := []byte{byte(StorageUpdate)} + buf = binary.AppendUvarint(buf, storageLen) + buf = append(buf, bytes.Repeat([]byte{0xAA}, int(storageLen))...) + + var u Update + _, err := u.Decode(buf, 0) + require.Error(t, err, "storage len %d", storageLen) + } +} + // TestUpdates_ArenaAlloc verifies that sequential allocations within a ring buffer return // non-overlapping sub-slices, and that an over-capacity request falls back to an independent // allocation that leaves prior sub-slices intact. diff --git a/execution/commitment/commitmentdb/commitment_context.go b/execution/commitment/commitmentdb/commitment_context.go index c3c395edd98..12b9fb22155 100644 --- a/execution/commitment/commitmentdb/commitment_context.go +++ b/execution/commitment/commitmentdb/commitment_context.go @@ -51,6 +51,10 @@ type sd interface { // per domain (Storage value loads vs Commitment branch reads // vs Account loads). Metrics() *kvmetrics.DomainMetrics + + // HasSharedBranchCache reports whether commitment-branch reads go through + // the aggregator-scope BranchCache shared across SharedDomains instances. + HasSharedBranchCache() bool } type SharedDomainsCommitmentContext struct { @@ -131,9 +135,19 @@ func (sdc *SharedDomainsCommitmentContext) EnableTrieWarmup(trieWarmup bool) { // instead of being applied inline. Used during fork validation where the update is // flushed later via FlushPendingUpdate. func (sdc *SharedDomainsCommitmentContext) SetDeferCommitmentUpdates(defer_ bool) { + if defer_ && sdc.variant == commitment.VariantBinPatriciaTrie { + panic(pbinUnsupported("deferred commitment updates")) + } sdc.deferCommitmentUpdates = defer_ } +// pbinUnsupported names a code path only the hex trie implements — deferred +// updates, collapse tracing, hex-prefixed branch reads, trie-trace replay — so +// asking for one under the bin variant fails instead of yielding a zero value. +func pbinUnsupported(what string) error { + return fmt.Errorf("%w: %s", commitment.ErrPBinUnsupported, what) +} + // TakePendingUpdate returns the pending update and clears the field. // Caller takes ownership of the returned value. func (sdc *SharedDomainsCommitmentContext) TakePendingUpdate() *commitment.PendingCommitmentUpdate { @@ -222,10 +236,18 @@ func NewSharedDomainsCommitmentContext(sd sd, mode commitment.Mode, tmpDir strin if variant == "" { variant = commitment.VariantHexPatriciaTrie } + if variant == commitment.VariantBinPatriciaTrie { + // The shared BranchCache indexes trunk slots by hex compact prefixes; + // distinct bin bit-path keys collapse onto one slot, so a shared cache + // would serve another node's record as a well-formed hit. + if sd != nil && sd.HasSharedBranchCache() { + panic("commitment variant " + string(variant) + " cannot use the shared branch cache: bit-path keys collide in its trunk slots") + } + } ctx := &SharedDomainsCommitmentContext{ sharedDomains: sd, tmpDir: tmpDir, - variant: commitment.VariantHexPatriciaTrie, + variant: variant, warmupBase: commitment.WarmupConfig{ Enabled: cfg.EnableTrieWarmup, NumWorkers: cfg.WarmupNumWorkersOrDefault(), @@ -237,6 +259,7 @@ func NewSharedDomainsCommitmentContext(sd sd, mode commitment.Mode, tmpDir strin // never wire one (RPC, integrity, tests) keep working under a global variant // selection. if variant == commitment.VariantParallelHexPatricia || variant == commitment.VariantStreamingHexPatricia { + ctx.variant = commitment.VariantHexPatriciaTrie ctx.pendingVariant = variant cfg.Variant = commitment.VariantHexPatriciaTrie ctx.pendingCfg = cfg @@ -251,12 +274,13 @@ func NewSharedDomainsCommitmentContext(sd sd, mode commitment.Mode, tmpDir strin // exclusively. Warmup/concurrent-mount readers get their own via the factories. func (sdc *SharedDomainsCommitmentContext) trieContext(tx kv.TemporalTx, blockNum, txNum uint64, readCtx context.Context) *TrieContext { mainTtx := &TrieContext{ - getter: sdc.sharedDomains.AsGetter(tx), - putter: sdc.sharedDomains.AsPutDel(tx), - stepSize: sdc.sharedDomains.StepSize(), - txNum: txNum, - blockNum: blockNum, - traceW: sdc.traceW, + getter: sdc.sharedDomains.AsGetter(tx), + putter: sdc.sharedDomains.AsPutDel(tx), + stepSize: sdc.sharedDomains.StepSize(), + txNum: txNum, + blockNum: blockNum, + traceW: sdc.traceW, + readCodeSize: sdc.variant == commitment.VariantBinPatriciaTrie, } if sdc.stateReader != nil { mainTtx.stateReader = sdc.stateReader.CloneForWorker(readCtx, tx) @@ -318,25 +342,60 @@ func (sdc *SharedDomainsCommitmentContext) TouchHashedKey(hashedKey []byte) { sdc.updates.TouchHashedKey(hashedKey) } +// witnessTrie is the capture seam: each engine walks its own tree and returns the +// nodes it hashed. Both variants implement it, so the capture names no concrete trie. +type witnessTrie interface { + Witnesses(ctx context.Context, updates *commitment.Updates, produceExclusionProofs bool, logPrefix string) (nodes [][]byte, provedKeys [][]byte, rootHash []byte, err error) +} + +var ( + _ witnessTrie = (*commitment.HexPatriciaHashed)(nil) + _ witnessTrie = (*commitment.PBinPatriciaHashed)(nil) +) + +// witnessBlockTrie is the seam for a trie whose key set depends on what the +// block did. Only the binary trie commits code, so only it implements this. +type witnessBlockTrie interface { + SetWitnessBlock(b commitment.PBinWitnessBlock) +} + +// SetWitnessBlock hands the next capture what the parent state it walks cannot +// say about the block. See commitment.PBinWitnessBlock. +func (sdc *SharedDomainsCommitmentContext) SetWitnessBlock(b commitment.PBinWitnessBlock) { + if trie, ok := sdc.Trie().(witnessBlockTrie); ok { + trie.SetWitnessBlock(b) + } +} + // witnessCapture runs the on-the-fly fold and returns the captured superset node // set (root first), the fold's hashed keys, and the root hash. func (sdc *SharedDomainsCommitmentContext) witnessCapture(ctx context.Context, produceExclusionProofs bool, logPrefix string) (nodes [][]byte, provedKeys [][]byte, rootHash []byte, err error) { - hexPatriciaHashed, ok := sdc.Trie().(*commitment.HexPatriciaHashed) + defer sdc.SetWitnessBlock(commitment.PBinWitnessBlock{}) // Witnesses clears it too, but only once it runs + + capturer, ok := sdc.Trie().(witnessTrie) if !ok { - return nil, nil, nil, errors.New("shared domains commitment context doesn't have HexPatriciaHashed") + return nil, nil, nil, fmt.Errorf("commitment trie %T captures no witness", sdc.Trie()) } - return hexPatriciaHashed.Witnesses(ctx, sdc.updates, produceExclusionProofs, logPrefix) + return capturer.Witnesses(ctx, sdc.updates, produceExclusionProofs, logPrefix) } // WitnessNodes builds the lean execution-witness node set: it prunes the captured -// superset to the proof paths of the fold's keys, returning the RLP node bytes -// (root first) and the root hash. This is the strict-verifier (reth) form. +// superset to the proof paths of the fold's keys, returning the node bytes (root +// first) and the root hash. This is the strict-verifier (reth) form. +// +// Each variant prunes with its own walker: the hex one is MPT-shaped and cannot +// read a bin preimage. func (sdc *SharedDomainsCommitmentContext) WitnessNodes(ctx context.Context, produceExclusionProofs bool, logPrefix string) (nodes [][]byte, rootHash []byte, err error) { full, provedKeys, rootHash, err := sdc.witnessCapture(ctx, produceExclusionProofs, logPrefix) if err != nil { return nil, nil, err } - lean, err := trie.WitnessNodesForKeysFromNodes(full, provedKeys) + var lean [][]byte + if sdc.variant == commitment.VariantBinPatriciaTrie { + lean, err = commitment.PBinWitnessNodesForKeys(full, rootHash, provedKeys) + } else { + lean, err = trie.WitnessNodesForKeysFromNodes(full, provedKeys) + } if err != nil { return nil, nil, fmt.Errorf("prune witness nodes: %w", err) } @@ -402,6 +461,9 @@ func (sdc *SharedDomainsCommitmentContext) WitnessLean(ctx context.Context, code // during commitment calculation. This is used by witness generation to capture paths // to HashNodes that need resolution when a FullNode is reduced to a single child. func (sdc *SharedDomainsCommitmentContext) SetCollapseTracer(tracer commitment.CollapseTracer) { + if tracer != nil && sdc.variant == commitment.VariantBinPatriciaTrie { + panic(pbinUnsupported("collapse tracing")) + } hexPatriciaHashed, ok := sdc.Trie().(*commitment.HexPatriciaHashed) if ok { hexPatriciaHashed.SetCollapseTracer(tracer) @@ -411,6 +473,9 @@ func (sdc *SharedDomainsCommitmentContext) SetCollapseTracer(tracer commitment.C // BranchChildCount returns the child count of the branch at nibblePrefix, read // from the in-memory commitment domain (post-compute state). func (sdc *SharedDomainsCommitmentContext) BranchChildCount(tx kv.TemporalTx, nibblePrefix []byte) (int, error) { + if sdc.variant == commitment.VariantBinPatriciaTrie { + return 0, pbinUnsupported("branch child count by hex nibble prefix") + } key := nibbles.HexToCompact(nibblePrefix) enc, _, err := sdc.sharedDomains.AsGetter(tx).GetLatest(kv.CommitmentDomain, key) if err != nil { @@ -419,6 +484,20 @@ func (sdc *SharedDomainsCommitmentContext) BranchChildCount(tx kv.TemporalTx, ni return commitment.BranchData(enc).ChildCount(), nil } +// trieTraceFile returns where blockNum's trie trace goes, or "" when tracing is +// off or aimed at another block. TRIE_TRACE_BLOCK alone picks a default path. +func trieTraceFile(blockNum uint64) string { + if dbg.TrieTraceBlock != 0 { + if blockNum != dbg.TrieTraceBlock { + return "" + } + if dbg.TrieTraceFile == "" { + return fmt.Sprintf("/tmp/trie-trace-block-%d.toml", blockNum) + } + } + return dbg.TrieTraceFile +} + // ComputeCommitment Evaluates commitment for gathered updates. // If warmup was set via EnableTrieWarmup, pre-warms MDBX page cache by reading Branch data in parallel before processing. // ComputeCommitment should normally be called via SharedDomains.ComputeCommitment, @@ -428,6 +507,15 @@ func (sdc *SharedDomainsCommitmentContext) ComputeCommitment(ctx context.Context if sdc.pendingUpdate != nil { panic("sdCtx.ComputeCommitment called directly with non-nil pendingUpdate; use SharedDomains.ComputeCommitment wrapper instead") } + traceFile := trieTraceFile(blockNum) + if sdc.variant == commitment.VariantBinPatriciaTrie { + switch { + case sdc.deferCommitmentUpdates: + return nil, pbinUnsupported("deferred commitment updates") + case traceFile != "": + return nil, pbinUnsupported("trie trace capture") + } + } if dbg.KVReadLevelledMetrics { mxCommitmentRunning.Inc() defer mxCommitmentRunning.Dec() @@ -449,6 +537,9 @@ func (sdc *SharedDomainsCommitmentContext) ComputeCommitment(ctx context.Context sdc.patriciaTrie.SetTraceWriter(sdc.traceW) if updateCount == 0 { + // The binary trie reads its stored root record here, so the trie has to be + // bound to this tx even on the path that touches nothing. + sdc.trieContext(tx, blockNum, txNum, ctx) rootHash, err = sdc.patriciaTrie.RootHash() return rootHash, err } @@ -465,16 +556,7 @@ func (sdc *SharedDomainsCommitmentContext) ComputeCommitment(ctx context.Context trieContext := sdc.trieContext(tx, blockNum, txNum, readCtx) - // If trie trace is configured, wrap the context with a recorder. - // Block-targeted: when TrieTraceBlock is set, only record that specific block. var recorder *commitment.RecordingContext - traceFile := dbg.TrieTraceFile - if traceFile == "" && dbg.TrieTraceBlock != 0 && blockNum == dbg.TrieTraceBlock { - // Auto-generate filename when only TRIE_TRACE_BLOCK is set without TRIE_TRACE_FILE. - traceFile = fmt.Sprintf("/tmp/trie-trace-block-%d.toml", blockNum) - } else if dbg.TrieTraceBlock != 0 && blockNum != dbg.TrieTraceBlock { - traceFile = "" // skip recording — not the target block - } if traceFile != "" { recorder = commitment.NewRecordingContext(trieContext) sdc.patriciaTrie.ResetContext(recorder) @@ -487,11 +569,8 @@ func (sdc *SharedDomainsCommitmentContext) ComputeCommitment(ctx context.Context // In production the trie has been restored via seekCommitment/SetState; // without this snapshot, replay starts from empty state and diverges. var trieState []byte - switch trie := sdc.patriciaTrie.(type) { - case *commitment.HexPatriciaHashed: - trieState, err = trie.EncodeCurrentState(nil) - case *commitment.ParallelPatriciaHashed: - trieState, err = trie.RootTrie().EncodeCurrentState(nil) + if st, ok := sdc.patriciaTrie.(commitment.StatefulTrie); ok { + trieState, err = st.EncodeCurrentState(nil) } if err != nil { log.Warn("[commitment] failed to encode trie state for trace", "err", err) @@ -680,11 +759,12 @@ func (sdc *SharedDomainsCommitmentContext) warmupTrieContextFactory(db kv.Tempor wm := kvmetrics.NewDomainMetrics() workerCtx := kvmetrics.ContextWithMetrics(ctx, wm) warmupCtx := &TrieContext{ - getter: sdc.sharedDomains.AsGetter(roTx), - putter: sdc.sharedDomains.AsPutDel(roTx), - stepSize: stepSize, - txNum: txNum, - traceW: sdc.traceW, + getter: sdc.sharedDomains.AsGetter(roTx), + putter: sdc.sharedDomains.AsPutDel(roTx), + stepSize: stepSize, + txNum: txNum, + traceW: sdc.traceW, + readCodeSize: sdc.variant == commitment.VariantBinPatriciaTrie, } if sdc.stateReader != nil { warmupCtx.stateReader = sdc.stateReader.CloneForWorker(workerCtx, roTx) @@ -732,6 +812,7 @@ func (sdc *SharedDomainsCommitmentContext) concurrentTrieContextFactory(db kv.Te txNum: txNum, localCollector: collector, traceW: sdc.traceW, + readCodeSize: sdc.variant == commitment.VariantBinPatriciaTrie, } if sdc.stateReader != nil { warmupCtx.stateReader = sdc.stateReader.CloneForWorker(workerCtx, roTx) @@ -791,9 +872,8 @@ func DecodeTxBlockNums(v []byte) (txNum, blockNum uint64) { // LatestCommitmentState searches for last encoded state for CommitmentContext. // Found value does not become current state. func (sdc *SharedDomainsCommitmentContext) LatestCommitmentState(trieContext *TrieContext) (blockNum, txNum uint64, state []byte, err error) { - tv := sdc.patriciaTrie.Variant() - if tv != commitment.VariantHexPatriciaTrie && tv != commitment.VariantParallelHexPatricia && tv != commitment.VariantStreamingHexPatricia { - return 0, 0, nil, errors.New("state storing is only supported hex patricia trie") + if _, ok := sdc.patriciaTrie.(commitment.StatefulTrie); !ok { + return 0, 0, nil, fmt.Errorf("commitment state is not supported by trie %T", sdc.patriciaTrie) } var step kv.Step @@ -884,23 +964,14 @@ func (sdc *SharedDomainsCommitmentContext) encodeAndStoreCommitmentState(trieCon // Encodes current trie state and returns it func (sdc *SharedDomainsCommitmentContext) encodeCommitmentState(blockNum, txNum uint64) ([]byte, error) { - var state []byte - var err error - - switch trie := (sdc.patriciaTrie).(type) { - case *commitment.HexPatriciaHashed: - state, err = trie.EncodeCurrentState(nil) - if err != nil { - return nil, err - } - case *commitment.ParallelPatriciaHashed: - state, err = trie.RootTrie().EncodeCurrentState(nil) - if err != nil { - return nil, err - } - default: + st, ok := sdc.patriciaTrie.(commitment.StatefulTrie) + if !ok { return nil, fmt.Errorf("unsupported state storing for patricia trie type: %T", sdc.patriciaTrie) } + state, err := st.EncodeCurrentState(nil) + if err != nil { + return nil, err + } cs := &commitmentState{trieState: state, blockNum: blockNum, txNum: txNum} encoded, err := cs.Encode() @@ -920,35 +991,17 @@ func (sdc *SharedDomainsCommitmentContext) restorePatriciaState(value []byte) (u } // nil value is acceptable for SetState and will reset trie } - tv := sdc.patriciaTrie.Variant() - - var hext *commitment.HexPatriciaHashed - var ppht *commitment.ParallelPatriciaHashed - if tv == commitment.VariantHexPatriciaTrie { - var ok bool - hext, ok = sdc.patriciaTrie.(*commitment.HexPatriciaHashed) - if !ok { - return 0, 0, errors.New("cannot typecast hex patricia trie") - } - } - if tv == commitment.VariantParallelHexPatricia || tv == commitment.VariantStreamingHexPatricia { - var ok bool - ppht, ok = sdc.patriciaTrie.(*commitment.ParallelPatriciaHashed) - if !ok { - return 0, 0, errors.New("cannot typecast parallel hex patricia trie") - } - hext = ppht.RootTrie() - } - if hext == nil { - return 0, 0, errors.New("unsupported trie variant: state restore requires a hex patricia trie") + st, ok := sdc.patriciaTrie.(commitment.StatefulTrie) + if !ok { + return 0, 0, fmt.Errorf("state restore is not supported by trie %T", sdc.patriciaTrie) } - if err := hext.SetState(cs.trieState); err != nil { + if err := st.SetState(cs.trieState); err != nil { return 0, 0, fmt.Errorf("failed restore state : %w", err) } sdc.justRestored.Store(true) // to prevent double reset if sdc.traceW != nil { - rootHash, err := hext.RootHash() + rootHash, err := sdc.patriciaTrie.RootHash() if err != nil { return 0, 0, fmt.Errorf("failed to get root hash after state restore: %w", err) } @@ -967,8 +1020,13 @@ type TrieContext struct { traceW io.Writer // nil = disabled; traces branch reads/writes (see [SDC] lines) stateReader StateReader localCollector *etl.Collector // per-goroutine collector for concurrent PutBranch + // readCodeSize makes Account resolve the account's code length. Only the + // binary trie hashes code_size, and the extra CodeDomain read is not free. + readCodeSize bool } +func (sdc *TrieContext) SetReadCodeSize(v bool) { sdc.readCodeSize = v } + // NewTrieContextRo creates a read-only TrieContext suitable for TrieReader lookups. // Only Branch() is functional; PutBranch/Account/Storage will return errors or nil. func NewTrieContextRo(reader StateReader, stepSize uint64) *TrieContext { @@ -1040,11 +1098,13 @@ func (sdc *TrieContext) Account(plainKey []byte) (u *commitment.Update, err erro u.CodeHash = acc.CodeHash.Value() } - // Verify only code-bearing accounts whose code is actually in the domain, - // and never fold the read into u. A cleared EIP-7702 delegation leaves a - // benign CodeDomain residue on a code-less account, and eth_simulateV1 - // overrides put code in an overlay the domain read doesn't see. - if dbg.AssertEnabled && !acc.IsEmptyCodeHash() { + // The read is keyed on the account's own code hash, never on what the + // CodeDomain happens to hold: a cleared EIP-7702 delegation leaves a residue + // there that no longer belongs to the account, so a code-less account keeps + // code_size 0. A code-bearing account with no code behind it would hash as + // code_size 0 instead — an eth_simulateV1 overlay the domain read doesn't + // see, or a truncated datadir — so it is an error rather than a wrong root. + if (sdc.readCodeSize || dbg.AssertEnabled) && !acc.IsEmptyCodeHash() { code, _, err := sdc.readDomain(kv.CodeDomain, plainKey) if err != nil { return nil, err @@ -1053,11 +1113,26 @@ func (sdc *TrieContext) Account(plainKey []byte) (u *commitment.Update, err erro if codeHash := crypto.Keccak256Hash(code); acc.CodeHash.Value() != codeHash { return nil, fmt.Errorf("code hash mismatch: account '%x' != codeHash '%x'", acc.CodeHash, codeHash[:]) } + } else if sdc.readCodeSize { + return nil, fmt.Errorf("code missing for account '%x' with codeHash '%x'", plainKey, acc.CodeHash.Value()) + } + if sdc.readCodeSize { + u.CodeSize = uint64(len(code)) } } return u, nil } +// Code serves the bytecode the binary trie chunks into leaves. Only that trie +// asks for it; the hex trie hashes an account's code hash and never its bytes. +func (sdc *TrieContext) Code(plainKey []byte) ([]byte, error) { + code, _, err := sdc.readDomain(kv.CodeDomain, plainKey) + if err != nil { + return nil, err + } + return code, nil +} + func (sdc *TrieContext) Storage(plainKey []byte) (u *commitment.Update, err error) { enc, _, err := sdc.readDomain(kv.StorageDomain, plainKey) if err != nil { diff --git a/execution/commitment/commitmentdb/commitment_context_test.go b/execution/commitment/commitmentdb/commitment_context_test.go index 11d04181f11..a6a669c0081 100644 --- a/execution/commitment/commitmentdb/commitment_context_test.go +++ b/execution/commitment/commitmentdb/commitment_context_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/execution/commitment" "github.com/stretchr/testify/require" ) @@ -83,3 +84,17 @@ func Test_TrieContext_BranchCopiesData(t *testing.T) { branch[1] = 8 require.Equal(t, []byte{9, 2, 3}, reader.branchData) } + +// Test_NewSharedDomainsCommitmentContext_AcceptsBinVariant pins that the bin +// variant constructs like any other stateful trie and carries its own variant +// tag instead of the hex default. +func Test_NewSharedDomainsCommitmentContext_AcceptsBinVariant(t *testing.T) { + t.Parallel() + + cfg := commitment.DefaultTrieConfig() + cfg.Variant = commitment.VariantBinPatriciaTrie + sdc := NewSharedDomainsCommitmentContext(nil, commitment.ModeDirect, t.TempDir(), cfg) + defer sdc.Close() + require.Equal(t, commitment.VariantBinPatriciaTrie, sdc.Trie().Variant()) + require.Equal(t, commitment.VariantBinPatriciaTrie, sdc.variant) +} diff --git a/execution/commitment/commitmentdb/pbin_code_test.go b/execution/commitment/commitmentdb/pbin_code_test.go new file mode 100644 index 00000000000..b4e8949e219 --- /dev/null +++ b/execution/commitment/commitmentdb/pbin_code_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitmentdb_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/commitment" +) + +func pbinTestCode(n int) []byte { + code := make([]byte, n) + for i := range code { + code[i] = byte(n + i) + } + return code +} + +// Code chunk leaves hold raw bytecode, which no account read returns and no +// other trie needs. +func TestPBinTrieContextCodeReadsCodeDomain(t *testing.T) { + t.Parallel() + + code := pbinTestCode(100) + addr := pbinCodeSizeAddr(6) + ttx := pbinCodeSizeTrieContext(t, true, addr, pbinCodeSizeAccount(crypto.Keccak256Hash(code)), code) + + got, err := ttx.Code(addr) + require.NoError(t, err) + require.Equal(t, code, got) + + absent, err := ttx.Code(pbinCodeSizeAddr(7)) + require.NoError(t, err) + require.Empty(t, absent) +} + +// The engine cross-checks the chunk count against the code_size it hashes, so a +// context that cannot serve code fails the commit instead of committing a +// code-less tree. Chunk values are pinned against the reference tree in the +// commitment package. +func TestPBinSharedDomainsCommitsCodeBearingAccount(t *testing.T) { + t.Parallel() + + cfg := commitment.DefaultTrieConfig() + cfg.Variant = commitment.VariantBinPatriciaTrie + + code := pbinTestCode(1000) + addr := pbinCodeSizeAddr(8) + acc := pbinCodeSizeAccount(crypto.Keccak256Hash(code)) + + sd, tx := pbinCodeSizeSharedDomains(t, []execctx.SharedDomainOption{execctx.WithTrieConfig(cfg)}, addr, acc, code) + require.IsType(t, &commitment.PBinPatriciaHashed{}, sd.GetCommitmentCtx().Trie()) + + withCode, err := sd.ComputeCommitment(t.Context(), tx, false, 0, 0, "pbin-code", nil) + require.NoError(t, err) + + // Chunk leaves are part of what is committed, not a side table. + short := pbinTestCode(1) + shortSd, shortTx := pbinCodeSizeSharedDomains(t, + []execctx.SharedDomainOption{execctx.WithTrieConfig(cfg)}, addr, pbinCodeSizeAccount(crypto.Keccak256Hash(short)), short) + withShortCode, err := shortSd.ComputeCommitment(t.Context(), shortTx, false, 0, 0, "pbin-code-short", nil) + require.NoError(t, err) + require.NotEqual(t, withShortCode, withCode) +} + +// The account header holds the first 128 code chunks; this code is one byte +// past that, so it spills into the code zone. +func TestPBinSharedDomainsCommitsCodeBeyondHeader(t *testing.T) { + t.Parallel() + + cfg := commitment.DefaultTrieConfig() + cfg.Variant = commitment.VariantBinPatriciaTrie + + code := pbinTestCode(128*31 + 1) + addr := pbinCodeSizeAddr(9) + sd, tx := pbinCodeSizeSharedDomains(t, + []execctx.SharedDomainOption{execctx.WithTrieConfig(cfg)}, addr, pbinCodeSizeAccount(crypto.Keccak256Hash(code)), code) + + overflowing, err := sd.ComputeCommitment(t.Context(), tx, false, 0, 0, "pbin-code-overflow", nil) + require.NoError(t, err) + + // Dropping the spilling byte must change the root: the overflow chunk is + // committed, not silently left out. + header := code[:len(code)-1] + headerSd, headerTx := pbinCodeSizeSharedDomains(t, + []execctx.SharedDomainOption{execctx.WithTrieConfig(cfg)}, addr, pbinCodeSizeAccount(crypto.Keccak256Hash(header)), header) + withinHeader, err := headerSd.ComputeCommitment(t.Context(), headerTx, false, 0, 0, "pbin-code-header", nil) + require.NoError(t, err) + require.NotEqual(t, withinHeader, overflowing) +} diff --git a/execution/commitment/commitmentdb/pbin_codesize_test.go b/execution/commitment/commitmentdb/pbin_codesize_test.go new file mode 100644 index 00000000000..2acca46cb11 --- /dev/null +++ b/execution/commitment/commitmentdb/pbin_codesize_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitmentdb_test + +import ( + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/commitment/commitmentdb" + "github.com/erigontech/erigon/execution/types/accounts" +) + +func pbinCodeSizeAddr(i byte) []byte { + a := make([]byte, length.Addr) + a[0], a[length.Addr-1] = 0xc0, i + return a +} + +func pbinCodeSizeSharedDomains(t *testing.T, opts []execctx.SharedDomainOption, addr []byte, acc *accounts.Account, code []byte) (*execctx.SharedDomains, kv.TemporalTx) { + t.Helper() + db := pbinNewTestDb(t) + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + t.Cleanup(tx.Rollback) + + sd, err := execctx.NewSharedDomains(t.Context(), tx, log.New(), opts...) + require.NoError(t, err) + t.Cleanup(sd.Close) + + require.NoError(t, sd.DomainPut(kv.AccountsDomain, tx, addr, accounts.SerialiseV3(acc), 0, nil)) + if code != nil { + require.NoError(t, sd.DomainPut(kv.CodeDomain, tx, addr, code, 0, nil)) + } + return sd, tx +} + +func pbinCodeSizeTrieContext(t *testing.T, readCodeSize bool, addr []byte, acc *accounts.Account, code []byte) *commitmentdb.TrieContext { + t.Helper() + sd, tx := pbinCodeSizeSharedDomains(t, nil, addr, acc, code) + ttx := commitmentdb.NewTrieContextRo(commitmentdb.NewLatestStateReader(tx, sd), sd.StepSize()) + ttx.SetReadCodeSize(readCodeSize) + return ttx +} + +func pbinCodeSizeAccount(codeHash common.Hash) *accounts.Account { + return &accounts.Account{Nonce: 3, Balance: *uint256.NewInt(77), CodeHash: accounts.InternCodeHash(codeHash)} +} + +// BASIC_DATA's code_size is the length of the account's code in the CodeDomain. +func TestPBinTrieContextAccountReadsCodeSize(t *testing.T) { + t.Parallel() + + code := []byte{0x60, 0x00, 0x60, 0x00, 0xfd} + addr := pbinCodeSizeAddr(1) + ttx := pbinCodeSizeTrieContext(t, true, addr, pbinCodeSizeAccount(crypto.Keccak256Hash(code)), code) + + u, err := ttx.Account(addr) + require.NoError(t, err) + require.Equal(t, uint64(len(code)), u.CodeSize) + require.NotZero(t, u.Flags&commitment.CodeUpdate) +} + +// The hex trie does not hash code_size, so it must not pay for the extra +// CodeDomain read. +func TestPBinTrieContextLeavesCodeSizeZeroForHex(t *testing.T) { + t.Parallel() + + code := []byte{0x60, 0x00, 0x60, 0x00, 0xfd} + addr := pbinCodeSizeAddr(2) + ttx := pbinCodeSizeTrieContext(t, false, addr, pbinCodeSizeAccount(crypto.Keccak256Hash(code)), code) + + u, err := ttx.Account(addr) + require.NoError(t, err) + require.Zero(t, u.CodeSize) +} + +// A cleared EIP-7702 delegation leaves code in the CodeDomain that no longer +// belongs to the account. code_size follows the account's own code hash, so the +// residue must not move the root. +func TestPBinTrieContextIgnoresClearedDelegationResidue(t *testing.T) { + t.Parallel() + + residue := []byte{0xef, 0x01, 0x00} + addr := pbinCodeSizeAddr(3) + ttx := pbinCodeSizeTrieContext(t, true, addr, pbinCodeSizeAccount(empty.CodeHash), residue) + + u, err := ttx.Account(addr) + require.NoError(t, err) + require.Zero(t, u.CodeSize) + require.Equal(t, empty.CodeHash, u.CodeHash) +} + +// A code hash with no code behind it (an eth_simulateV1 overlay, a truncated +// datadir) would hash as code_size 0 and silently produce a wrong root. +func TestPBinTrieContextRefusesCodeBearingAccountWithoutCode(t *testing.T) { + t.Parallel() + + addr := pbinCodeSizeAddr(4) + ttx := pbinCodeSizeTrieContext(t, true, addr, pbinCodeSizeAccount(common.Hash{0xAB}), nil) + + _, err := ttx.Account(addr) + require.ErrorContains(t, err, "code missing") +} + +// Only a bin SharedDomains must insist the code is there. +func TestPBinSharedDomainsReadsCodeSizeUnderBin(t *testing.T) { + t.Parallel() + + cfg := commitment.DefaultTrieConfig() + cfg.Variant = commitment.VariantBinPatriciaTrie + + addr := pbinCodeSizeAddr(5) + acc := pbinCodeSizeAccount(common.Hash{0xAB}) + sd, tx := pbinCodeSizeSharedDomains(t, []execctx.SharedDomainOption{execctx.WithTrieConfig(cfg)}, addr, acc, nil) + require.IsType(t, &commitment.PBinPatriciaHashed{}, sd.GetCommitmentCtx().Trie()) + + _, err := sd.ComputeCommitment(t.Context(), tx, false, 0, 0, "pbin-codesize", nil) + require.ErrorContains(t, err, "code missing") + + hexSd, hexTx := pbinCodeSizeSharedDomains(t, nil, addr, acc, nil) + _, err = hexSd.ComputeCommitment(t.Context(), hexTx, false, 0, 0, "hex-codesize", nil) + require.NoError(t, err, "hex does not hash code_size and must not start requiring the code") +} diff --git a/execution/commitment/commitmentdb/pbin_nocache_test.go b/execution/commitment/commitmentdb/pbin_nocache_test.go new file mode 100644 index 00000000000..cccc0d24f5d --- /dev/null +++ b/execution/commitment/commitmentdb/pbin_nocache_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitmentdb_test + +import ( + "fmt" + "testing" + + "github.com/c2h5oh/datasize" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/dbcfg" + "github.com/erigontech/erigon/db/kv/mdbx" + "github.com/erigontech/erigon/db/kv/temporal" + "github.com/erigontech/erigon/db/state" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/kvmetrics" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/commitment/commitmentdb" +) + +type pbinStubSharedDomains struct{ sharedCache bool } + +func (s *pbinStubSharedDomains) SetTxNum(uint64) {} +func (s *pbinStubSharedDomains) AsGetter(kv.TemporalTx) kv.TemporalGetter { return nil } +func (s *pbinStubSharedDomains) AsPutDel(kv.TemporalTx) kv.TemporalPutDel { return nil } +func (s *pbinStubSharedDomains) MergeMetrics(kvmetrics.Source, *kvmetrics.DomainMetrics) {} +func (s *pbinStubSharedDomains) StepSize() uint64 { return 1 } +func (s *pbinStubSharedDomains) Metrics() *kvmetrics.DomainMetrics { return nil } +func (s *pbinStubSharedDomains) HasSharedBranchCache() bool { return s.sharedCache } + +func pbinRecoverMessage(t *testing.T, fn func()) (msg string) { + t.Helper() + defer func() { + if r := recover(); r != nil { + msg = fmt.Sprint(r) + } + }() + fn() + return "" +} + +// Why bin must not share the cache: TestPBinBranchCacheTrunkSlotCollision. +func TestPBinCtorRefusesSharedBranchCache(t *testing.T) { + t.Parallel() + + cfg := commitment.DefaultTrieConfig() + cfg.Variant = commitment.VariantBinPatriciaTrie + msg := pbinRecoverMessage(t, func() { + commitmentdb.NewSharedDomainsCommitmentContext(&pbinStubSharedDomains{sharedCache: true}, commitment.ModeDirect, t.TempDir(), cfg) + }) + require.Contains(t, msg, "branch cache") +} + +// The reason the bin variant must not share the BranchCache: the trunk-slot +// index reads a prefix as a hex compact path, injective only for hex keys. A +// pbin bit-path key is packed MSB-first bits plus a trailing bitLen%8 byte, so +// distinct short paths land on one slot and the cache serves another node's +// record as a well-formed hit. +func TestPBinBranchCacheTrunkSlotCollision(t *testing.T) { + t.Parallel() + + cache := commitment.NewBranchCache(commitment.DefaultBranchCacheTailCapacity) + defer cache.Close() + + // 3-bit path 000 and 3-bit path 001: both index depth-2 slot d2[0x03]. + keyA := []byte{0x00, 0x03} + keyB := []byte{0x20, 0x03} + dataA := []byte{0xde, 0xad, 0xbe, 0xef} + + cache.Put(keyA, dataA, 1, 1) + got, _, ok := cache.Get(keyB) + require.True(t, ok, "distinct bit-path key no longer collides — revisit whether the bin variant may share the BranchCache") + require.Equal(t, dataA, got) +} + +func pbinNewTestDb(tb testing.TB) kv.TemporalRwDB { + tb.Helper() + logger := log.New() + dirs := datadir.New(tb.TempDir()) + db := mdbx.New(dbcfg.ChainDB, logger).InMem(tb, dirs.Chaindata).GrowthStep(32 * datasize.MB).MapSize(2 * datasize.GB).MustOpen() + tb.Cleanup(db.Close) + + agg := state.NewTest(dirs).StepSize(16).Logger(logger).MustOpen(tb.Context(), db) + tb.Cleanup(agg.Close) + require.NoError(tb, agg.OpenFolder()) + tdb, err := temporal.New(db, agg, nil) + require.NoError(tb, err) + return tdb +} + +// execctx must strip the AggTx's shared BranchCache before the bin commitment +// context is constructed, and still open. +func TestPBinSharedDomainsHasNoSharedBranchCache(t *testing.T) { + t.Parallel() + + db := pbinNewTestDb(t) + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + cfg := commitment.DefaultTrieConfig() + cfg.Variant = commitment.VariantBinPatriciaTrie + + var sd *execctx.SharedDomains + msg := pbinRecoverMessage(t, func() { + var sdErr error + sd, sdErr = execctx.NewSharedDomains(t.Context(), tx, log.New(), execctx.WithTrieConfig(cfg)) + require.NoError(t, sdErr) + }) + require.Empty(t, msg) + defer sd.Close() + require.False(t, sd.HasSharedBranchCache()) +} diff --git a/execution/commitment/commitmentdb/pbin_state_header_test.go b/execution/commitment/commitmentdb/pbin_state_header_test.go new file mode 100644 index 00000000000..394a613f0a9 --- /dev/null +++ b/execution/commitment/commitmentdb/pbin_state_header_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitmentdb + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/kvmetrics" + "github.com/erigontech/erigon/execution/commitment" +) + +type pbinStateStubSD struct{} + +func (s *pbinStateStubSD) SetTxNum(uint64) {} +func (s *pbinStateStubSD) AsGetter(kv.TemporalTx) kv.TemporalGetter { return nil } +func (s *pbinStateStubSD) AsPutDel(kv.TemporalTx) kv.TemporalPutDel { return nil } +func (s *pbinStateStubSD) MergeMetrics(kvmetrics.Source, *kvmetrics.DomainMetrics) {} +func (s *pbinStateStubSD) StepSize() uint64 { return 1 } +func (s *pbinStateStubSD) Metrics() *kvmetrics.DomainMetrics { return nil } +func (s *pbinStateStubSD) HasSharedBranchCache() bool { return false } + +func pbinStateTestCtx(t *testing.T, variant commitment.TrieVariant) *SharedDomainsCommitmentContext { + t.Helper() + cfg := commitment.DefaultTrieConfig() + cfg.Variant = variant + sdc := NewSharedDomainsCommitmentContext(&pbinStateStubSD{}, commitment.ModeDirect, t.TempDir(), cfg) + t.Cleanup(sdc.Close) + return sdc +} + +// DecodeTxBlockNums and LatestBlockNumWithCommitment read the 16-byte +// txNum‖blockNum header raw and variant-blind. +func TestPBinCommitmentStateHeaderMatchesHex(t *testing.T) { + t.Parallel() + + const blockNum, txNum = uint64(41), uint64(4321) + + hexCtx := pbinStateTestCtx(t, commitment.VariantHexPatriciaTrie) + hexState, err := hexCtx.encodeCommitmentState(blockNum, txNum) + require.NoError(t, err) + + binCtx := pbinStateTestCtx(t, commitment.VariantBinPatriciaTrie) + require.Equal(t, commitment.VariantBinPatriciaTrie, binCtx.variant) + binState, err := binCtx.encodeCommitmentState(blockNum, txNum) + require.NoError(t, err) + + require.Equal(t, hexState[:16], binState[:16], "the txNum‖blockNum header must stay byte-identical across variants") + + gotTx, gotBlock := DecodeTxBlockNums(binState) + require.Equal(t, txNum, gotTx) + require.Equal(t, blockNum, gotBlock) + + restoredBlock, restoredTx, err := binCtx.restorePatriciaState(binState) + require.NoError(t, err) + require.Equal(t, blockNum, restoredBlock) + require.Equal(t, txNum, restoredTx) +} diff --git a/execution/commitment/commitmentdb/pbin_unsupported_test.go b/execution/commitment/commitmentdb/pbin_unsupported_test.go new file mode 100644 index 00000000000..adc561cce90 --- /dev/null +++ b/execution/commitment/commitmentdb/pbin_unsupported_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitmentdb + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/execution/commitment" +) + +func pbinRecoveredError(t *testing.T, fn func()) (err error) { + t.Helper() + defer func() { + r := recover() + if r == nil { + return + } + recovered, ok := r.(error) + require.True(t, ok, "panic value must carry the error: %v", r) + err = recovered + }() + fn() + return nil +} + +// Silently accepting the request would leave the flag set with no trie +// honouring it: Process would apply inline while the caller waited for a flush. +func TestPBinRefusesDeferredCommitmentUpdates(t *testing.T) { + t.Parallel() + + hexCtx := pbinStateTestCtx(t, commitment.VariantHexPatriciaTrie) + hexCtx.SetDeferCommitmentUpdates(true) + require.True(t, hexCtx.deferCommitmentUpdates) + + binCtx := pbinStateTestCtx(t, commitment.VariantBinPatriciaTrie) + err := pbinRecoveredError(t, func() { binCtx.SetDeferCommitmentUpdates(true) }) + require.ErrorIs(t, err, commitment.ErrPBinUnsupported) + require.False(t, binCtx.deferCommitmentUpdates, "the refused request must not leave the flag set") + + require.NoError(t, pbinRecoveredError(t, func() { binCtx.SetDeferCommitmentUpdates(false) })) +} + +// Were the flag ever set under bin, the post-Process type switch would find no +// trie carrying deferred updates and hand back an empty pendingUpdate. +func TestPBinComputeCommitmentRefusesDeferredTake(t *testing.T) { + t.Parallel() + + binCtx := pbinStateTestCtx(t, commitment.VariantBinPatriciaTrie) + binCtx.deferCommitmentUpdates = true + + _, err := binCtx.ComputeCommitment(t.Context(), nil, false, 1, 1, "test", nil) + require.ErrorIs(t, err, commitment.ErrPBinUnsupported) +} + +// The trace replays recorded branch records through the hex trie, so a bin +// trace would replay as a different tree. +func TestPBinComputeCommitmentRefusesTrieTrace(t *testing.T) { + prev := dbg.TrieTraceFile + dbg.TrieTraceFile = t.TempDir() + "/trie-trace.toml" + t.Cleanup(func() { dbg.TrieTraceFile = prev }) + + binCtx := pbinStateTestCtx(t, commitment.VariantBinPatriciaTrie) + _, err := binCtx.ComputeCommitment(t.Context(), nil, false, 1, 1, "test", nil) + require.ErrorIs(t, err, commitment.ErrPBinUnsupported) + + hexCtx := pbinStateTestCtx(t, commitment.VariantHexPatriciaTrie) + _, err = hexCtx.ComputeCommitment(t.Context(), nil, false, 1, 1, "test", nil) + require.NoError(t, err) +} + +// The tracer only ever reaches a HexPatriciaHashed, so under bin it would be +// installed nowhere and the caller would collect no collapse paths. +func TestPBinRefusesCollapseTracer(t *testing.T) { + t.Parallel() + + tracer := func(hashedKeyPath, branchPrefix []byte) {} + + hexCtx := pbinStateTestCtx(t, commitment.VariantHexPatriciaTrie) + require.NoError(t, pbinRecoveredError(t, func() { hexCtx.SetCollapseTracer(tracer) })) + + binCtx := pbinStateTestCtx(t, commitment.VariantBinPatriciaTrie) + err := pbinRecoveredError(t, func() { binCtx.SetCollapseTracer(tracer) }) + require.ErrorIs(t, err, commitment.ErrPBinUnsupported) + + require.NoError(t, pbinRecoveredError(t, func() { binCtx.SetCollapseTracer(nil) }), "clearing must stay allowed") +} + +// The prefix is a hex nibble path compacted into a commitment key, which +// addresses no bin record — the read would miss and report a child count of zero. +func TestPBinBranchChildCountRefusesBin(t *testing.T) { + t.Parallel() + + binCtx := pbinStateTestCtx(t, commitment.VariantBinPatriciaTrie) + _, err := binCtx.BranchChildCount(nil, []byte{0x0a, 0x0b}) + require.ErrorIs(t, err, commitment.ErrPBinUnsupported) +} diff --git a/execution/commitment/commitmentdb/pbin_witness_test.go b/execution/commitment/commitmentdb/pbin_witness_test.go new file mode 100644 index 00000000000..3283e3c09e0 --- /dev/null +++ b/execution/commitment/commitmentdb/pbin_witness_test.go @@ -0,0 +1,255 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitmentdb + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/commitment/trie" +) + +// pbinWitnessState is the in-memory PatriciaContext both engines are driven +// over: branch records they write, plus the accounts and slots they read back. +type pbinWitnessState struct { + branches map[string][]byte + accounts map[string]*commitment.Update + storage map[string]*commitment.Update +} + +func newPBinWitnessState() *pbinWitnessState { + return &pbinWitnessState{ + branches: make(map[string][]byte), + accounts: make(map[string]*commitment.Update), + storage: make(map[string]*commitment.Update), + } +} + +func (s *pbinWitnessState) Branch(prefix []byte) ([]byte, kv.Step, error) { + return s.branches[string(prefix)], 0, nil +} + +func (s *pbinWitnessState) PutBranch(prefix, data, prevData []byte) error { + s.branches[string(prefix)] = bytes.Clone(data) + return nil +} + +func (s *pbinWitnessState) Account(plainKey []byte) (*commitment.Update, error) { + if u, ok := s.accounts[string(plainKey)]; ok { + return u, nil + } + return new(commitment.Update), nil +} + +func (s *pbinWitnessState) Storage(plainKey []byte) (*commitment.Update, error) { + if u, ok := s.storage[string(plainKey)]; ok { + return u, nil + } + return new(commitment.Update), nil +} + +// Code satisfies the seam the binary update stream type-asserts for; the corpus +// carries no code, so it is never asked for any. +func (s *pbinWitnessState) Code(plainKey []byte) ([]byte, error) { return nil, nil } + +func (s *pbinWitnessState) addAccount(addr []byte, nonce, balance uint64) []byte { + u := &commitment.Update{Flags: commitment.BalanceUpdate | commitment.NonceUpdate, Nonce: nonce} + u.Balance.SetUint64(balance) + u.CodeHash = empty.CodeHash + s.accounts[string(addr)] = u + return addr +} + +func (s *pbinWitnessState) addStorage(addr, slot []byte, val byte) []byte { + key := append(bytes.Clone(addr), slot...) + u := &commitment.Update{Flags: commitment.StorageUpdate, StorageLen: 1} + u.Storage[length.Hash-1] = val + s.storage[string(key)] = u + return key +} + +type pbinWitnessTouch struct { + domain kv.Domain + key []byte +} + +// pbinWitnessDBCorpus spans two accounts and their slots, so a witness over it +// captures branch nodes rather than a bare root. +func pbinWitnessDBCorpus(state *pbinWitnessState) []pbinWitnessTouch { + var touches []pbinWitnessTouch + for i := byte(1); i <= 4; i++ { + addr := bytes.Repeat([]byte{i}, length.Addr) + touches = append(touches, pbinWitnessTouch{kv.AccountsDomain, state.addAccount(addr, uint64(i), uint64(i)*1000)}) + for _, slot := range []byte{0, 7, 64} { + key := bytes.Repeat([]byte{slot}, length.Hash) + touches = append(touches, pbinWitnessTouch{kv.StorageDomain, state.addStorage(addr, key, i)}) + } + } + return touches +} + +// pbinWitnessTrieCtx wires a fresh engine of the given variant over state. The +// witness pass runs on its own engine so it starts from the stored records +// rather than from a folded one left behind by the build. +func pbinWitnessTrieCtx(t *testing.T, variant commitment.TrieVariant, state *pbinWitnessState) *SharedDomainsCommitmentContext { + t.Helper() + sdc := pbinStateTestCtx(t, variant) + sdc.patriciaTrie.ResetContext(state) + return sdc +} + +func pbinWitnessTouchAll(sdc *SharedDomainsCommitmentContext, touches []pbinWitnessTouch) { + for _, touch := range touches { + sdc.TouchKey(touch.domain, string(touch.key), nil) + } +} + +func pbinWitnessCommit(t *testing.T, variant commitment.TrieVariant, state *pbinWitnessState, touches []pbinWitnessTouch) []byte { + t.Helper() + sdc := pbinWitnessTrieCtx(t, variant, state) + pbinWitnessTouchAll(sdc, touches) + root, err := sdc.patriciaTrie.Process(t.Context(), sdc.updates, "test", nil, commitment.WarmupConfig{}) + require.NoError(t, err) + return bytes.Clone(root) +} + +// pbinWitnessCapture builds the corpus under variant and then captures a witness +// over the same touches, returning the committed root alongside the capture. +func pbinWitnessCapture(t *testing.T, variant commitment.TrieVariant) (nodes, provedKeys [][]byte, root, committedRoot []byte, err error) { + t.Helper() + state := newPBinWitnessState() + touches := pbinWitnessDBCorpus(state) + committedRoot = pbinWitnessCommit(t, variant, state, touches) + + sdc := pbinWitnessTrieCtx(t, variant, state) + pbinWitnessTouchAll(sdc, touches) + nodes, provedKeys, root, err = sdc.witnessCapture(t.Context(), false, "test") + return nodes, provedKeys, root, committedRoot, err +} + +// TestPBinWitnessCaptureServesBothVariants: the capture used to type-assert the +// hex engine, so the bin variant failed before it ever walked a tree. +func TestPBinWitnessCaptureServesBothVariants(t *testing.T) { + t.Parallel() + + for _, variant := range []commitment.TrieVariant{commitment.VariantHexPatriciaTrie, commitment.VariantBinPatriciaTrie} { + t.Run(string(variant), func(t *testing.T) { + t.Parallel() + + nodes, provedKeys, root, committedRoot, err := pbinWitnessCapture(t, variant) + require.NoError(t, err) + require.Equal(t, committedRoot, root, "the capture must return the pre-state root") + require.Len(t, root, length.Hash) + require.Greater(t, len(nodes), 1, "a corpus this wide must capture more than the root node") + require.NotEmpty(t, provedKeys) + }) + } +} + +// TestPBinWitnessCaptureHexUnchanged pins the hex capture against the engine +// called directly, so the interface dispatch cannot alter what hex returns. +func TestPBinWitnessCaptureHexUnchanged(t *testing.T) { + t.Parallel() + + state := newPBinWitnessState() + touches := pbinWitnessDBCorpus(state) + committedRoot := pbinWitnessCommit(t, commitment.VariantHexPatriciaTrie, state, touches) + + viaCapture := pbinWitnessTrieCtx(t, commitment.VariantHexPatriciaTrie, state) + pbinWitnessTouchAll(viaCapture, touches) + nodes, provedKeys, root, err := viaCapture.witnessCapture(t.Context(), true, "test") + require.NoError(t, err) + + direct := pbinWitnessTrieCtx(t, commitment.VariantHexPatriciaTrie, state) + pbinWitnessTouchAll(direct, touches) + hph, ok := direct.Trie().(*commitment.HexPatriciaHashed) + require.True(t, ok) + wantNodes, wantKeys, wantRoot, err := hph.Witnesses(t.Context(), direct.updates, true, "test") + require.NoError(t, err) + + require.Equal(t, committedRoot, wantRoot) + require.Equal(t, wantRoot, root) + require.Equal(t, wantKeys, provedKeys) + require.Equal(t, wantNodes[0], nodes[0], "root node must stay first") + require.ElementsMatch(t, wantNodes, nodes) +} + +// TestPBinWitnessNodesPrunesPerVariant: the lean set is cut by the walker that +// can read the capture — the MPT one cannot follow a binary preimage. +func TestPBinWitnessNodesPrunesPerVariant(t *testing.T) { + t.Parallel() + + for _, variant := range []commitment.TrieVariant{commitment.VariantHexPatriciaTrie, commitment.VariantBinPatriciaTrie} { + t.Run(string(variant), func(t *testing.T) { + t.Parallel() + + state := newPBinWitnessState() + touches := pbinWitnessDBCorpus(state) + committedRoot := pbinWitnessCommit(t, variant, state, touches) + + capture := pbinWitnessTrieCtx(t, variant, state) + pbinWitnessTouchAll(capture, touches) + full, provedKeys, root, err := capture.witnessCapture(t.Context(), false, "test") + require.NoError(t, err) + + sdc := pbinWitnessTrieCtx(t, variant, state) + pbinWitnessTouchAll(sdc, touches) + lean, rootHash, err := sdc.WitnessNodes(t.Context(), false, "test") + require.NoError(t, err) + require.Equal(t, committedRoot, rootHash) + require.NotEmpty(t, lean) + + want := trie.WitnessNodesForKeysFromNodes + if variant == commitment.VariantBinPatriciaTrie { + want = func(nodes, keys [][]byte) ([][]byte, error) { + return commitment.PBinWitnessNodesForKeys(nodes, root, keys) + } + } + wantNodes, err := want(full, provedKeys) + require.NoError(t, err) + require.Equal(t, wantNodes[0], lean[0], "root node must stay first") + require.ElementsMatch(t, wantNodes, lean) + }) + } +} + +// pbinWitnessCaptureLessTrie is a Trie that captures no witness, standing in for +// the parallel variants the capture cannot serve. +type pbinWitnessCaptureLessTrie struct{ commitment.Trie } + +func (pbinWitnessCaptureLessTrie) Release() {} + +// TestPBinWitnessCaptureRejectsUnknownTrie: falling through to a nil capturer +// would panic instead of naming the trie that cannot serve the request. +func TestPBinWitnessCaptureRejectsUnknownTrie(t *testing.T) { + t.Parallel() + + sdc := pbinStateTestCtx(t, commitment.VariantHexPatriciaTrie) + sdc.patriciaTrie = pbinWitnessCaptureLessTrie{} + + _, _, _, err := sdc.witnessCapture(context.Background(), false, "test") + require.Error(t, err) + require.Contains(t, err.Error(), "pbinWitnessCaptureLessTrie") + require.Contains(t, err.Error(), "captures no witness") +} diff --git a/execution/commitment/hex_patricia_hashed_test.go b/execution/commitment/hex_patricia_hashed_test.go index eea014c3d9d..37d5a276190 100644 --- a/execution/commitment/hex_patricia_hashed_test.go +++ b/execution/commitment/hex_patricia_hashed_test.go @@ -956,6 +956,7 @@ func TestUpdate_EncodeDecode(t *testing.T) { {Flags: BalanceUpdate, Balance: *uint256.NewInt(123), CodeHash: empty.CodeHash}, {Flags: BalanceUpdate | NonceUpdate, Balance: *uint256.NewInt(45639015), Nonce: 123, CodeHash: empty.CodeHash}, {Flags: BalanceUpdate | NonceUpdate | CodeUpdate, Balance: *uint256.NewInt(45639015), Nonce: 123, + CodeSize: 24576, CodeHash: common.Hash{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, @@ -978,6 +979,7 @@ func TestUpdate_EncodeDecode(t *testing.T) { require.Equal(t, update.Balance, decoded.Balance, i) require.Equal(t, update.Nonce, decoded.Nonce, i) require.Equal(t, update.CodeHash, decoded.CodeHash, i) + require.Equal(t, update.CodeSize, decoded.CodeSize, i) require.Equal(t, update.Storage, decoded.Storage, i) require.Equal(t, update.StorageLen, decoded.StorageLen, i) } diff --git a/execution/commitment/parallel_patricia_hashed.go b/execution/commitment/parallel_patricia_hashed.go index 58fb0006ff5..7891f48d323 100644 --- a/execution/commitment/parallel_patricia_hashed.go +++ b/execution/commitment/parallel_patricia_hashed.go @@ -98,6 +98,19 @@ func (p *ParallelPatriciaHashed) RootTrie() *HexPatriciaHashed { return p.template } +// EncodeCurrentState and SetState delegate to the template trie, which is where +// the live root state lives; they make the parallel trie a StatefulTrie. +func (p *ParallelPatriciaHashed) EncodeCurrentState(buf []byte) ([]byte, error) { + return p.template.EncodeCurrentState(buf) +} + +// A restore moves the root, so a root published by an earlier Process no longer +// describes the trie and RootHash has to fall back to the template. +func (p *ParallelPatriciaHashed) SetState(buf []byte) error { + p.rootHash.Store(nil) + return p.template.SetState(buf) +} + // Reset clears the published root hash and resets the template so the instance // can be reused; pooled workers stay cached for the next Process call. func (p *ParallelPatriciaHashed) Reset() { diff --git a/execution/commitment/parallel_patricia_hashed_test.go b/execution/commitment/parallel_patricia_hashed_test.go index a260cada6b3..33e1da4f57f 100644 --- a/execution/commitment/parallel_patricia_hashed_test.go +++ b/execution/commitment/parallel_patricia_hashed_test.go @@ -126,6 +126,23 @@ func TestParallelPatriciaHashedSkeletonReset(t *testing.T) { require.NotNil(t, p.template, "Reset preserves the template") } +// A restore moves the root, so RootHash must report the restored template +// rather than a root an earlier Process published. +func TestParallelPatriciaHashedSetStateDropsPublishedRoot(t *testing.T) { + p := NewParallelPatriciaHashed(nil, length.Addr, DefaultTrieConfig()) + stashed := []byte{0xde, 0xad} + p.rootHash.Store(&stashed) + + require.NoError(t, p.SetState(nil)) + assert.Nil(t, p.rootHash.Load(), "SetState clears the published rootHash") + + restored, err := p.RootHash() + require.NoError(t, err) + templateRoot, err := p.template.RootHash() + require.NoError(t, err) + assert.Equal(t, templateRoot, restored) +} + // Every checkout must be config-correct whether it hit the shared pool or // constructed fresh — that fungibility is what lets workers cross instances. func TestWorkerCheckoutAppliesConfig(t *testing.T) { diff --git a/execution/commitment/patricia_state_mock_test.go b/execution/commitment/patricia_state_mock_test.go index 9b6d9d330e7..3a3d6e6750f 100644 --- a/execution/commitment/patricia_state_mock_test.go +++ b/execution/commitment/patricia_state_mock_test.go @@ -43,15 +43,17 @@ type MockState struct { mu sync.RWMutex // to protect sm and cm for concurrent trie sm map[string][]byte // backbone of the state cm map[string]BranchData // backbone of the commitments + code map[string][]byte // bytecode by account plain key, what CodeDomain holds numBuf [binary.MaxVarintLen64]byte } func NewMockState(t testing.TB) *MockState { t.Helper() return &MockState{ - t: t, - sm: make(map[string][]byte), - cm: make(map[string]BranchData), + t: t, + sm: make(map[string][]byte), + cm: make(map[string]BranchData), + code: make(map[string][]byte), } } @@ -158,6 +160,19 @@ func (ms *MockState) Storage(plainKey []byte) (*Update, error) { return &ex, nil } +// Code stands in for the CodeDomain read the binary trie's code chunking needs. +func (ms *MockState) Code(plainKey []byte) ([]byte, error) { + if ms.concurrent.Load() { + ms.mu.RLock() + defer ms.mu.RUnlock() + } + return ms.code[string(plainKey)], nil +} + +func (ms *MockState) setCode(addr, code []byte) { + ms.code[string(addr)] = bytes.Clone(code) +} + func (ms *MockState) TxNum() uint64 { return 0 } // applyPlainUpdates is called sequentially outside of the trie, so it needs no locking. diff --git a/execution/commitment/pbin_adversarial_test.go b/execution/commitment/pbin_adversarial_test.go new file mode 100644 index 00000000000..b06095861c7 --- /dev/null +++ b/execution/commitment/pbin_adversarial_test.go @@ -0,0 +1,129 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/empty" +) + +// Intra-batch sequences and the group-boundary shape the vendored corpus does +// not reach. No vector pins them, so the canonical-rebuild oracle is the +// reference throughout. A key touched twice in one corpus is one batch touching +// it twice: state and oracle both keep the last write. + +func pbinTestIndicator(fill byte) []byte { + return append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{fill}, 20)...) +} + +func TestPBinDelegationSetAndClearedInOneBatch(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(101) + indicator := pbinTestIndicator(0x33) + corpus := new(pbinTestCorpus). + accountWithCodeBytes(addr, 1, 10, indicator). + accountWithCodeBytes(addr, 2, 10, nil) + _, root := corpus.process(t) + + cleared := new(pbinTestCorpus).account(addr, 2, 10, empty.CodeHash) + require.Equal(t, cleared.oracleRoot(t), root, + "a delegation set and cleared inside one batch ends at the empty-code CODE_HASH leaf") + require.Equal(t, corpus.oracleRoot(t), root) + + delegation := pbinEncodeDelegation(indicator) + leftBehind := append(cleared.entries(t), + pbinOracleEntry{key: pbinTreeKeyAccount(addr, pbinDelegationLeafKey), value: delegation[:]}) + wrong := pbinOracleRoot(leftBehind) + require.NotEqual(t, wrong[:], root, "the mid-batch indicator must not survive the clear") +} + +func TestPBinDelegationRepointedInOneBatch(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(102) + prior, mid, final := pbinTestIndicator(0x44), pbinTestIndicator(0x55), pbinTestIndicator(0x66) + stored := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, prior) + repoint := new(pbinTestCorpus). + accountWithCodeBytes(addr, 2, 10, mid). + accountWithCodeBytes(addr, 3, 10, final) + _, _, root := pbinTestBatches(t, stored, repoint) + + want := new(pbinTestCorpus).accountWithCodeBytes(addr, 3, 10, final) + require.Equal(t, want.oracleRoot(t), root, + "two authorizations in one batch leave one delegation leaf holding the last target") + + stale := new(pbinTestCorpus).accountWithCodeBytes(addr, 3, 10, mid) + require.NotEqual(t, stale.oracleRoot(t), root, "the earlier target must not survive the repoint") + + asCode := new(pbinTestCorpus).accountWithCode(addr, 3, 10, keccak.Sum256(final), uint64(len(final))) + require.NotEqual(t, asCode.oracleRoot(t), root, "no code-hash leaf may appear for a delegated account") +} + +func TestPBinZeroChunkAloneInItsGroup(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(103) + code := append(pbinTestCode(pbinStemSubtreeWidth*pbinChunkDataLen), make([]byte, pbinChunkDataLen)...) + chunks := pbinChunkifyCode(code) + require.Len(t, chunks, pbinStemSubtreeWidth+1) + require.Equal(t, [pbinValueLength]byte{}, chunks[pbinStemSubtreeWidth], + "the sole chunk of group 1 must be all-zero, PUSHDATA count included") + + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 5, code) + _, root := corpus.process(t) + require.Equal(t, corpus.oracleRoot(t), root, + "a zero chunk alone in its tree_index group leaves the group with no leaf at all") + + withLeaf := append(corpus.entries(t), pbinOracleEntry{ + key: pbinTreeKeyCodeChunk(keccak.Sum256(code), pbinStemSubtreeWidth), + value: make([]byte, pbinValueLength), + }) + wrong := pbinOracleRoot(withLeaf) + require.NotEqual(t, wrong[:], root, "materializing the zero chunk as a leaf must change the root") +} + +func TestPBinSharedCodeOutlivesOneHolder(t *testing.T) { + t.Parallel() + + holder, doomed := pbinOracleAddr(104), pbinOracleAddr(105) + code := pbinTestCode(31 * 3) + both := new(pbinTestCorpus). + accountWithCodeBytes(holder, 1, 5, code). + accountWithCodeBytes(doomed, 2, 7, code) + + pph, ms := pbinTestEngine(t) + both.applyTo(t, ms) + pbinTestProcess(t, pph, both.plainKeys, both.updates) + + removal := [][]byte{doomed} + require.NoError(t, ms.applyPlainUpdates(removal, []Update{{Flags: DeleteUpdate}})) + pph.Reset() + root := pbinTestProcess(t, pph, removal, []Update{{Flags: DeleteUpdate}}) + + survivor := new(pbinTestCorpus).accountWithCodeBytes(holder, 1, 5, code) + require.Equal(t, survivor.oracleRoot(t), root, + "deleting one holder leaves the shared chunk set with the other") + + noChunks := new(pbinTestCorpus).accountWithCode(holder, 1, 5, keccak.Sum256(code), uint64(len(code))) + require.NotEqual(t, noChunks.oracleRoot(t), root, "the survivor's chunks must not go with the removed holder") +} diff --git a/execution/commitment/pbin_bitpath.go b/execution/commitment/pbin_bitpath.go new file mode 100644 index 00000000000..6588422a7d0 --- /dev/null +++ b/execution/commitment/pbin_bitpath.go @@ -0,0 +1,213 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "errors" + "fmt" + "math/bits" +) + +const ( + // pbinMaxPathBits is the longest EIP-8297 tree key: 66 bytes for a storage leaf. + pbinMaxPathBits = 528 + pbinPathWords = (pbinMaxPathBits + 63) / 64 +) + +// pbinBitpath is a path of up to 528 bits through the binary tree, held as +// big-endian words so that word order equals descent order and divergence is a +// XOR plus LeadingZeros64. Bits at or past bitLen are not part of the path and +// may hold anything; every reader either clamps by bitLen or masks first. +type pbinBitpath struct { + w [pbinPathWords]uint64 + bitLen int16 +} + +func pbinPathFromBytes(b []byte) pbinBitpath { + return pbinPathFromBits(b, int16(len(b)*8)) +} + +func pbinPathFromBits(b []byte, bitLen int16) pbinBitpath { + if bitLen < 0 || bitLen > pbinMaxPathBits { + panic(fmt.Sprintf("pbin: bit length %d out of range", bitLen)) + } + var p pbinBitpath + n := min((int(bitLen)+7)/8, len(b)) + for i := range n { + p.w[i/8] |= uint64(b[i]) << (56 - 8*uint(i%8)) + } + p.bitLen = bitLen + p.maskTail() + return p +} + +func (p *pbinBitpath) bit(d int16) uint64 { + if d < 0 || d >= p.bitLen { + panic(fmt.Sprintf("pbin: bit %d out of range for %d-bit path", d, p.bitLen)) + } + return (p.w[d/64] >> (63 - uint(d%64))) & 1 +} + +func (p *pbinBitpath) setBitAt(d int16, v uint64) { + if d < 0 || d >= pbinMaxPathBits { + panic(fmt.Sprintf("pbin: bit %d out of range", d)) + } + m := uint64(1) << (63 - uint(d%64)) + if v != 0 { + p.w[d/64] |= m + } else { + p.w[d/64] &^= m + } +} + +func (p *pbinBitpath) maskTail() { + wi, off := int(p.bitLen)/64, uint(p.bitLen)%64 + if off == 0 { + p.w[wi] = 0 + } else { + p.w[wi] &= ^uint64(0) << (64 - off) + } + for i := wi + 1; i < pbinPathWords; i++ { + p.w[i] = 0 + } +} + +func (p *pbinBitpath) truncate(bitLen int16) { + if bitLen < 0 || bitLen > p.bitLen { + panic(fmt.Sprintf("pbin: cannot truncate %d-bit path to %d bits", p.bitLen, bitLen)) + } + p.bitLen = bitLen + p.maskTail() +} + +func (p *pbinBitpath) slice(from, to int16) pbinBitpath { + if from < 0 || to < from || to > p.bitLen { + panic(fmt.Sprintf("pbin: slice [%d,%d) out of range for %d-bit path", from, to, p.bitLen)) + } + var r pbinBitpath + for i := from; i < to; i++ { + r.setBitAt(i-from, p.bit(i)) + } + r.bitLen = to - from + return r +} + +func (p *pbinBitpath) appendBit(v uint64) { + p.setBitAt(p.bitLen, v) + p.bitLen++ +} + +func (p *pbinBitpath) append(o *pbinBitpath) { + if int(p.bitLen)+int(o.bitLen) > pbinMaxPathBits { + panic(fmt.Sprintf("pbin: appending %d bits to %d-bit path overflows", o.bitLen, p.bitLen)) + } + for i := int16(0); i < o.bitLen; i++ { + p.setBitAt(p.bitLen+i, o.bit(i)) + } + p.bitLen += o.bitLen +} + +func (p *pbinBitpath) hasPrefix(o *pbinBitpath) bool { + return o.bitLen <= p.bitLen && pbinCommonPrefixBitsAt(p, 0, o) == o.bitLen +} + +// pbinCommonPrefixBitsAt reports how many leading bits of prefix agree with key +// read from bit `from`, clamped to what both operands hold. +func pbinCommonPrefixBitsAt(key *pbinBitpath, from int16, prefix *pbinBitpath) int16 { + limit := min(key.bitLen-from, prefix.bitLen) + if limit <= 0 { + return 0 + } + shift := uint(from % 64) + n := int16(0) + for wi := int(from / 64); n < limit; wi++ { + w := key.w[wi] << shift + if shift != 0 && wi+1 < pbinPathWords { + w |= key.w[wi+1] >> (64 - shift) + } + if x := w ^ prefix.w[n/64]; x != 0 { + n += int16(bits.LeadingZeros64(x)) + break + } + n += 64 + } + return min(n, limit) +} + +// appendPackedBits appends the path's bits MSB-first, zero-padded to a byte +// boundary. +func (p *pbinBitpath) appendPackedBits(dst []byte) []byte { + for i := range (int(p.bitLen) + 7) / 8 { + dst = append(dst, byte(p.w[i/8]>>(56-8*uint(i%8)))) + } + if used := p.bitLen % 8; used != 0 { + dst[len(dst)-1] &= ^byte(0) << (8 - uint(used)) + } + return dst +} + +var ( + errPBinEmptyBitPath = errors.New("pbin: empty bit-path key") + errPBinNonCanonicalPad = errors.New("pbin: non-canonical padding in bit-path key") +) + +// pbinAppendBitPath appends the DB key for p: packed bits followed by one byte +// holding bitLen mod 8. The count is a suffix so that a subtree stays +// contiguous; a leading length field would scatter its records across the +// keyspace. The order is not ancestors-before-descendants, and callers must not +// assume it is. +func pbinAppendBitPath(dst []byte, p *pbinBitpath) []byte { + return append(p.appendPackedBits(dst), byte(p.bitLen%8)) +} + +func pbinEncodeBitPath(p *pbinBitpath) []byte { + return pbinAppendBitPath(make([]byte, 0, (int(p.bitLen)+7)/8+1), p) +} + +// pbinDecodeBitPath inverts pbinAppendBitPath, rejecting non-canonical +// spellings so that one path has exactly one DB key. +func pbinDecodeBitPath(buf []byte) (pbinBitpath, error) { + var p pbinBitpath + if len(buf) == 0 { + return p, errPBinEmptyBitPath + } + tailBits, packed := buf[len(buf)-1], buf[:len(buf)-1] + if tailBits > 7 { + return p, fmt.Errorf("pbin: invalid trailing bit count %d in bit-path key", tailBits) + } + bitLen := len(packed) * 8 + if tailBits != 0 { + if len(packed) == 0 { + return p, fmt.Errorf("pbin: trailing bit count %d with no payload", tailBits) + } + bitLen = bitLen - 8 + int(tailBits) + } + if bitLen > pbinMaxPathBits { + return p, fmt.Errorf("pbin: bit path of %d bits exceeds %d", bitLen, pbinMaxPathBits) + } + for i, b := range packed { + p.w[i/8] |= uint64(b) << (56 - 8*uint(i%8)) + } + p.bitLen = int16(bitLen) + + masked := p + masked.maskTail() + if masked.w != p.w { + return pbinBitpath{}, errPBinNonCanonicalPad + } + return p, nil +} diff --git a/execution/commitment/pbin_bitpath_test.go b/execution/commitment/pbin_bitpath_test.go new file mode 100644 index 00000000000..2ffea095594 --- /dev/null +++ b/execution/commitment/pbin_bitpath_test.go @@ -0,0 +1,268 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func pbinTestPath(t *testing.T, pattern byte, bitLen int16) pbinBitpath { + t.Helper() + p := pbinPathFromBits(bytes.Repeat([]byte{pattern}, 66), bitLen) + require.Equal(t, bitLen, p.bitLen) + return p +} + +func pbinFlipBit(p pbinBitpath, at int16) pbinBitpath { + p.setBitAt(at, p.bit(at)^1) + return p +} + +func TestPBinCommonPrefixBits(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + aLen int16 + bLen int16 + flipAt int16 // -1: no divergence + want int16 + }{ + {"equal-271", 271, 271, -1, 271}, + {"equal-272", 272, 272, -1, 272}, + {"equal-273", 273, 273, -1, 273}, + {"equal-527", 527, 527, -1, 527}, + {"equal-528", 528, 528, -1, 528}, + {"diff-at-0", 528, 528, 0, 0}, + {"diff-at-63", 528, 528, 63, 63}, + {"diff-at-64", 528, 528, 64, 64}, + {"diff-at-270-len-271", 271, 271, 270, 270}, + {"diff-at-271-len-272", 272, 272, 271, 271}, + {"diff-at-272-len-273", 273, 273, 272, 272}, + {"diff-at-526-len-527", 527, 527, 526, 526}, + {"diff-at-527-len-528", 528, 528, 527, 527}, + } { + t.Run(tc.name, func(t *testing.T) { + a := pbinTestPath(t, 0xA5, tc.aLen) + b := pbinTestPath(t, 0xA5, tc.bLen) + if tc.flipAt >= 0 { + b = pbinFlipBit(b, tc.flipAt) + } + require.Equal(t, tc.want, pbinCommonPrefixBitsAt(&a, 0, &b)) + require.Equal(t, tc.want, pbinCommonPrefixBitsAt(&b, 0, &a)) + }) + } +} + +// Without clamping by min(aLen, bLen) the words keep agreeing past the shorter +// path's end, so an account key that prefixes a storage key over-reports. +func TestPBinCommonPrefixBits_ShorterPathIsPrefix(t *testing.T) { + t.Parallel() + + long := pbinTestPath(t, 0xAA, 528) + short := pbinTestPath(t, 0xAA, 272) + + require.Equal(t, int16(272), pbinCommonPrefixBitsAt(&short, 0, &long)) + require.Equal(t, int16(272), pbinCommonPrefixBitsAt(&long, 0, &short)) +} + +// Words carrying set bits beyond bitLen must not be read as real path bits. +func TestPBinCommonPrefixBits_IgnoresBitsBeyondBitLen(t *testing.T) { + t.Parallel() + + long := pbinTestPath(t, 0xAA, 528) + + dirty := pbinTestPath(t, 0xAA, 272) + dirty.w[4] |= 0x0000FFFFFFFFFFFF // bits 272..319 + for i := 5; i < pbinPathWords; i++ { + dirty.w[i] = ^uint64(0) + } + + require.Equal(t, int16(272), pbinCommonPrefixBitsAt(&dirty, 0, &long)) + require.Equal(t, int16(272), pbinCommonPrefixBitsAt(&long, 0, &dirty)) + + clean := pbinTestPath(t, 0xAA, 272) + dirty.maskTail() + require.Equal(t, clean.w, dirty.w) +} + +func TestPBinBitpathAccessors(t *testing.T) { + t.Parallel() + + p := pbinPathFromBytes([]byte{0b10110001, 0b01000000}) + require.Equal(t, int16(16), p.bitLen) + for i, want := range []uint64{1, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0} { + require.Equalf(t, want, p.bit(int16(i)), "bit %d", i) + } + + mid := p.slice(3, 11) + require.Equal(t, int16(8), mid.bitLen) + require.Equal(t, pbinPathFromBytes([]byte{0b10001010}), mid) + + head, tail := p.slice(0, 3), p.slice(11, 16) + head.append(&mid) + head.append(&tail) + require.Equal(t, p, head) + + empty, short := p.slice(0, 0), p.slice(0, 7) + require.True(t, p.hasPrefix(&empty)) + require.True(t, p.hasPrefix(&short)) + require.True(t, p.hasPrefix(&p)) + + flipped := pbinFlipBit(p, 5) + other := flipped.slice(0, 7) + require.False(t, p.hasPrefix(&other)) + require.False(t, short.hasPrefix(&p)) + + var appended pbinBitpath + for i := int16(0); i < p.bitLen; i++ { + appended.appendBit(p.bit(i)) + } + require.Equal(t, p, appended) + + truncated := p + truncated.truncate(4) + require.Equal(t, pbinPathFromBits([]byte{0b10110000}, 4), truncated) +} + +func TestPBinBitPathCodecRoundTrip(t *testing.T) { + t.Parallel() + + src := make([]byte, 66) + for i := range src { + src[i] = byte(i*7 + 1) + } + + for bitLen := int16(0); bitLen <= pbinMaxPathBits; bitLen++ { + p := pbinPathFromBits(src, bitLen) + enc := pbinEncodeBitPath(&p) + require.Equalf(t, (int(bitLen)+7)/8+1, len(enc), "bitLen %d", bitLen) + require.LessOrEqual(t, len(enc), 67) + + got, err := pbinDecodeBitPath(enc) + require.NoErrorf(t, err, "bitLen %d", bitLen) + require.Equalf(t, p, got, "bitLen %d", bitLen) + } +} + +func TestPBinBitPathCodecEmpty(t *testing.T) { + t.Parallel() + + var empty pbinBitpath + require.Equal(t, []byte{0x00}, pbinEncodeBitPath(&empty)) + + got, err := pbinDecodeBitPath([]byte{0x00}) + require.NoError(t, err) + require.Equal(t, empty, got) +} + +func TestPBinBitPathCodecRejects(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + buf []byte + }{ + {"empty-key", nil}, + {"tail-count-out-of-range", []byte{0xE0, 0x08}}, + {"tail-count-is-a-byte", []byte{0xE0, 0xFF}}, + {"tail-count-without-payload", []byte{0x05}}, + {"non-canonical-pad", []byte{0xFF, 0x03}}, + {"non-canonical-pad-single-bit", []byte{0x40, 0x01}}, + {"too-long", append(bytes.Repeat([]byte{0xAA}, 67), 0x00)}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := pbinDecodeBitPath(tc.buf) + require.Error(t, err) + }) + } + + got, err := pbinDecodeBitPath([]byte{0xE0, 0x03}) + require.NoError(t, err) + require.Equal(t, pbinPathFromBits([]byte{0xE0}, 3), got) +} + +// The commitment domain stores its state blob under the literal key "state", so +// no encoded bit path may collide with it. +func TestPBinBitPathNeverEncodesToStateKey(t *testing.T) { + t.Parallel() + + _, err := pbinDecodeBitPath(KeyCommitmentState) + require.Error(t, err) + + src := bytes.Repeat([]byte{0x74}, 66) + for bitLen := int16(0); bitLen <= pbinMaxPathBits; bitLen++ { + p := pbinPathFromBits(src, bitLen) + require.NotEqualf(t, KeyCommitmentState, pbinEncodeBitPath(&p), "bitLen %d", bitLen) + } +} + +func FuzzPBinBitPathCodec(f *testing.F) { + f.Add([]byte{}, uint16(0)) + f.Add([]byte{0x00}, uint16(1)) + f.Add(bytes.Repeat([]byte{0xFF}, 66), uint16(528)) + f.Add(bytes.Repeat([]byte{0xA5}, 34), uint16(272)) + f.Add([]byte{0xFF, 0x03}, uint16(3)) + + f.Fuzz(func(t *testing.T, data []byte, n uint16) { + bitLen := int16(int(n) % (pbinMaxPathBits + 1)) + p := pbinPathFromBits(data, bitLen) + + enc := pbinEncodeBitPath(&p) + got, err := pbinDecodeBitPath(enc) + require.NoError(t, err) + require.Equal(t, p, got) + + // Decoding is total and canonical: anything that decodes must re-encode + // to the very bytes it came from, so one bit path has one DB key. + if q, err := pbinDecodeBitPath(data); err == nil { + require.Equal(t, data, pbinEncodeBitPath(&q)) + } + }) +} + +// The word-at-a-time scan must agree with a bit-by-bit walk at every offset, +// including the ones that straddle a word boundary. +func TestPBinCommonPrefixBitsAt_MatchesNaiveScan(t *testing.T) { + t.Parallel() + + naive := func(key *pbinBitpath, from int16, prefix *pbinBitpath) int16 { + limit := min(key.bitLen-from, prefix.bitLen) + n := int16(0) + for n < limit && key.bit(from+n) == prefix.bit(n) { + n++ + } + return n + } + + key := pbinTestPath(t, 0x6D, pbinMaxPathBits) + for _, from := range []int16{0, 1, 7, 63, 64, 65, 127, 128, 271, 272, 511, 512, 527, 528} { + for _, want := range []int16{0, 1, 63, 64, 65, 128, 271} { + p := key.slice(from, min(from+want, key.bitLen)) + require.Equalf(t, naive(&key, from, &p), pbinCommonPrefixBitsAt(&key, from, &p), + "from %d, %d-bit prefix", from, p.bitLen) + for flip := int16(0); flip < p.bitLen; flip++ { + d := pbinFlipBit(p, flip) + require.Equalf(t, naive(&key, from, &d), pbinCommonPrefixBitsAt(&key, from, &d), + "from %d, %d-bit prefix flipped at %d", from, p.bitLen, flip) + } + } + } +} diff --git a/execution/commitment/pbin_branch.go b/execution/commitment/pbin_branch.go new file mode 100644 index 00000000000..d8008850b86 --- /dev/null +++ b/execution/commitment/pbin_branch.go @@ -0,0 +1,265 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "errors" + "fmt" + "math/bits" + + "github.com/erigontech/erigon/common/length" +) + +// pbinCellBits masks the only child slots a binary node has. +const pbinCellBits = 0b11 + +type pbinCellFields uint8 + +const ( + pbinFieldLeaf pbinCellFields = 1 + pbinFieldBranch pbinCellFields = 2 + pbinFieldAccountAddr pbinCellFields = 4 + pbinFieldStorageAddr pbinCellFields = 8 + pbinFieldHash pbinCellFields = 16 + // pbinFieldLeafValue carries the leaf's own 32 bytes. A code chunk is the one + // value no state domain holds — chunking is a property of the tree, not of the + // account — so the record is where it lives. + pbinFieldLeafValue pbinCellFields = 32 + + pbinFieldsAll = pbinFieldLeaf | pbinFieldBranch | pbinFieldAccountAddr | pbinFieldStorageAddr | + pbinFieldHash | pbinFieldLeafValue + pbinFieldKind = pbinFieldLeaf | pbinFieldBranch + pbinFieldValue = pbinFieldAccountAddr | pbinFieldStorageAddr | pbinFieldLeafValue +) + +var ( + errPBinMalformedBranch = errors.New("pbin: malformed branch record") + errPBinCellMaps = errors.New("pbin: branch maps address more than two cells") +) + +// pbinBranchEncoder serialises a binary node. The payload is not BranchData: a +// 66-byte tree-key prefix does not fit the shared codec's cell fields, and +// PatriciaContext moves branch payloads as opaque bytes. +// +// Every record carries both child cells, so a record read back replaces its +// predecessor outright and there is no merge-with-previous path: at arity 2 the +// untouched sibling is the whole other half of the subtree, and merging loses it. +type pbinBranchEncoder struct { + buf []byte +} + +func (e *pbinBranchEncoder) encode(touchMap, afterMap uint16, cells *[2]pbinCell) ([]byte, error) { + if err := pbinCheckCellMaps(touchMap, afterMap); err != nil { + return nil, err + } + e.buf = binary.BigEndian.AppendUint16(e.buf[:0], touchMap) + e.buf = binary.BigEndian.AppendUint16(e.buf, afterMap) + + var err error + for bitset := afterMap; bitset != 0; { + bit := bitset & -bitset + if e.buf, err = pbinAppendCell(e.buf, &cells[bits.TrailingZeros16(bit)]); err != nil { + return nil, err + } + bitset ^= bit + } + return e.buf, nil +} + +func pbinAppendCell(dst []byte, c *pbinCell) ([]byte, error) { + var fields pbinCellFields + switch c.kind { + case pbinNodeLeaf: + fields = pbinFieldLeaf + case pbinNodeBranch: + fields = pbinFieldBranch + default: + return nil, fmt.Errorf("%w: cell present in afterMap has no node kind", errPBinMalformedBranch) + } + if c.accountAddrLen > 0 { + fields |= pbinFieldAccountAddr + } + if c.storageAddrLen > 0 { + fields |= pbinFieldStorageAddr + } + if c.kind == pbinNodeLeaf && fields&pbinFieldValue == 0 { + fields |= pbinFieldLeafValue + } + if c.hashLen > 0 { + fields |= pbinFieldHash + } + + dst = append(dst, byte(fields)) + dst = binary.AppendUvarint(dst, uint64(c.prefix.bitLen)) + dst = c.prefix.appendPackedBits(dst) + + if fields&pbinFieldAccountAddr != 0 { + dst = pbinAppendLenAndVal(dst, c.accountAddr[:c.accountAddrLen]) + } + if fields&pbinFieldStorageAddr != 0 { + dst = pbinAppendLenAndVal(dst, c.storageAddr[:c.storageAddrLen]) + } + if fields&pbinFieldLeafValue != 0 { + value, err := pbinRecordLeafValue(&c.Update) + if err != nil { + return nil, err + } + dst = pbinAppendLenAndVal(dst, value[:]) + } + if fields&pbinFieldHash != 0 { + dst = pbinAppendLenAndVal(dst, c.hash[:c.hashLen]) + } + return dst, nil +} + +func pbinAppendLenAndVal(dst, val []byte) []byte { + return append(binary.AppendUvarint(dst, uint64(len(val))), val...) +} + +// pbinDecodeBranch fills both cells from a record. It rejects every spelling the +// encoder would not produce, so a record has one canonical form. +func pbinDecodeBranch(data []byte, cells *[2]pbinCell) (touchMap, afterMap uint16, err error) { + cells[0].reset() + cells[1].reset() + + if len(data) < 4 { + return 0, 0, fmt.Errorf("%w: %d bytes is shorter than the header", errPBinMalformedBranch, len(data)) + } + touchMap, afterMap = binary.BigEndian.Uint16(data), binary.BigEndian.Uint16(data[2:]) + if err := pbinCheckCellMaps(touchMap, afterMap); err != nil { + return 0, 0, err + } + + pos := 4 + for bitset := afterMap; bitset != 0; { + bit := bitset & -bitset + if pos, err = pbinDecodeCell(data, pos, &cells[bits.TrailingZeros16(bit)]); err != nil { + return 0, 0, err + } + bitset ^= bit + } + if pos != len(data) { + return 0, 0, fmt.Errorf("%w: %d trailing bytes", errPBinMalformedBranch, len(data)-pos) + } + return touchMap, afterMap, nil +} + +func pbinDecodeCell(data []byte, pos int, c *pbinCell) (int, error) { + if pos >= len(data) { + return 0, fmt.Errorf("%w: no cell body at offset %d", errPBinMalformedBranch, pos) + } + fields := pbinCellFields(data[pos]) + pos++ + if fields&^pbinFieldsAll != 0 { + return 0, fmt.Errorf("%w: unknown cell fields %08b", errPBinMalformedBranch, fields) + } + switch fields & pbinFieldKind { + case pbinFieldLeaf: + c.kind = pbinNodeLeaf + // A leaf whose value has no source would hash a zero-valued state instead of + // failing, so reject the shape here rather than let it reach the hasher. + switch fields & pbinFieldValue { + case pbinFieldAccountAddr, pbinFieldStorageAddr, pbinFieldLeafValue: + default: + return 0, fmt.Errorf("%w: leaf cell fields %08b name no single value source", errPBinMalformedBranch, fields) + } + case pbinFieldBranch: + c.kind = pbinNodeBranch + if fields&pbinFieldLeafValue != 0 { + return 0, fmt.Errorf("%w: branch cell carries a leaf value", errPBinMalformedBranch) + } + default: + return 0, fmt.Errorf("%w: cell fields %08b name no single node kind", errPBinMalformedBranch, fields) + } + + pos, err := pbinDecodePrefix(data, pos, c) + if err != nil { + return 0, err + } + if fields&pbinFieldAccountAddr != 0 { + if pos, err = pbinDecodeFixedVal(data, pos, c.accountAddr[:], length.Addr); err != nil { + return 0, err + } + c.accountAddrLen = length.Addr + } + if fields&pbinFieldStorageAddr != 0 { + if pos, err = pbinDecodeFixedVal(data, pos, c.storageAddr[:], length.Addr+length.Hash); err != nil { + return 0, err + } + c.storageAddrLen = length.Addr + length.Hash + } + if fields&pbinFieldLeafValue != 0 { + if pos, err = pbinDecodeFixedVal(data, pos, c.Storage[:], pbinValueLength); err != nil { + return 0, err + } + c.Flags, c.StorageLen = StorageUpdate, pbinValueLength + } + if fields&pbinFieldHash != 0 { + if pos, err = pbinDecodeFixedVal(data, pos, c.hash[:], length.Hash); err != nil { + return 0, err + } + c.hashLen = length.Hash + } + return pos, nil +} + +// pbinDecodePrefix trusts the explicit bit count, not the byte length: pad bits +// left to speak for themselves would carry up to seven extra bits into the +// branch hash. +func pbinDecodePrefix(data []byte, pos int, c *pbinCell) (int, error) { + bitLen, n := binary.Uvarint(data[pos:]) + if n <= 0 { + return 0, fmt.Errorf("%w: unreadable prefix bit count at offset %d", errPBinMalformedBranch, pos) + } + pos += n + if bitLen > pbinMaxPathBits { + return 0, fmt.Errorf("%w: prefix of %d bits exceeds %d", errPBinMalformedBranch, bitLen, pbinMaxPathBits) + } + byteLen := (int(bitLen) + 7) / 8 + if pos+byteLen > len(data) { + return 0, fmt.Errorf("%w: prefix of %d bits needs %d bytes, %d left", errPBinMalformedBranch, bitLen, byteLen, len(data)-pos) + } + if used := bitLen % 8; used != 0 && data[pos+byteLen-1]&(0xFF>>used) != 0 { + return 0, fmt.Errorf("%w: non-zero pad bits after a %d-bit prefix", errPBinMalformedBranch, bitLen) + } + c.prefix = pbinPathFromBits(data[pos:pos+byteLen], int16(bitLen)) + return pos + byteLen, nil +} + +func pbinDecodeFixedVal(data []byte, pos int, dst []byte, want int) (int, error) { + l, n := binary.Uvarint(data[pos:]) + if n <= 0 { + return 0, fmt.Errorf("%w: unreadable value length at offset %d", errPBinMalformedBranch, pos) + } + pos += n + if l != uint64(want) { + return 0, fmt.Errorf("%w: value of %d bytes, want %d", errPBinMalformedBranch, l, want) + } + if pos+want > len(data) { + return 0, fmt.Errorf("%w: value of %d bytes needs more than the %d left", errPBinMalformedBranch, want, len(data)-pos) + } + copy(dst, data[pos:pos+want]) + return pos + want, nil +} + +func pbinCheckCellMaps(touchMap, afterMap uint16) error { + if (touchMap|afterMap)&^pbinCellBits != 0 { + return fmt.Errorf("%w: touch %016b after %016b", errPBinCellMaps, touchMap, afterMap) + } + return nil +} diff --git a/execution/commitment/pbin_cell.go b/execution/commitment/pbin_cell.go new file mode 100644 index 00000000000..a4c7793ec1a --- /dev/null +++ b/execution/commitment/pbin_cell.go @@ -0,0 +1,108 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// pbinNodeKind says what a cell points at. EIP-8297 admits a branch whose +// prefix is empty, so the prefix length cannot stand in for the kind. +type pbinNodeKind uint8 + +const ( + pbinNodeEmpty pbinNodeKind = iota + pbinNodeLeaf + pbinNodeBranch +) + +// pbinCell is one of the two child slots of a binary node. Its prefix is always +// tree-key-space bits: unlike the hex engine's cell it needs no second key +// space, because PBin derives the tree key from the plain key on demand. +// +// A branch cell's prefix is inside its hash, so re-cutting the prefix +// invalidates it. Two invariants keep that from going unnoticed: a non-zero +// hashLen means hash covers the prefix the cell holds now, and childrenSet means +// the cell can re-derive the hash for any prefix without touching the database. +type pbinCell struct { + prefix pbinBitpath + hash common.Hash + children [2]common.Hash + accountAddr common.Address + storageAddr [length.Addr + length.Hash]byte + + accountAddrLen int16 + storageAddrLen int16 + hashLen int16 + kind pbinNodeKind + childrenSet bool + loaded loadFlags + Update +} + +func (c *pbinCell) setFromUpdate(u *Update) { c.Update.Merge(u) } + +func (c *pbinCell) reset() { + *c = pbinCell{} + c.Update.Reset() +} + +// pbinGridRows bounds the active rows: a row consumes at least the bit it splits +// on, so one row per path bit is enough. +const pbinGridRows = pbinMaxPathBits + +// pbinGrid is the unfolded part of the tree: one row per level of descent, two +// cells per row. touchMap/afterMap are uint16 so the OnesCount16 / +// TrailingZeros16 arithmetic ports from the hex engine unchanged; only bits 0 +// and 1 are ever set. +type pbinGrid struct { + root pbinCell + rows [pbinGridRows][2]pbinCell + depths [pbinGridRows]int16 + branchBefore [pbinGridRows]bool + prevRecord [pbinGridRows][]byte + touchMap [pbinGridRows]uint16 + afterMap [pbinGridRows]uint16 + activeRows int +} + +// resetForReuse clears only the rows below activeRows. The stale cells above +// are safe because unfold initializes a row before anything reads it. +func (g *pbinGrid) resetForReuse() { + g.root.reset() + for row := range g.activeRows { + g.rows[row][0].reset() + g.rows[row][1].reset() + g.depths[row] = 0 + g.branchBefore[row] = false + g.prevRecord[row] = nil + g.touchMap[row] = 0 + g.afterMap[row] = 0 + } + g.activeRows = 0 +} + +// prevRecordFor returns the bytes the row unfolded from, zero-length when it had +// no record. Never nil, so the write layer takes it as the known previous value +// instead of reading the store itself. +func (g *pbinGrid) prevRecordFor(row int) []byte { + if g.prevRecord[row] == nil { + return []byte{} + } + return g.prevRecord[row] +} diff --git a/execution/commitment/pbin_cell_test.go b/execution/commitment/pbin_cell_test.go new file mode 100644 index 00000000000..3e5aeabb25e --- /dev/null +++ b/execution/commitment/pbin_cell_test.go @@ -0,0 +1,369 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/common/length" +) + +func pbinTestEmptyCell() pbinCell { + var c pbinCell + c.reset() + return c +} + +func pbinTestBranchCell(pattern byte, bitLen int16) pbinCell { + c := pbinTestEmptyCell() + c.kind = pbinNodeBranch + c.prefix = pbinPathFromBits(bytes.Repeat([]byte{pattern}, 66), bitLen) + for i := range c.hash { + c.hash[i] = pattern ^ byte(i) + } + c.hashLen = length.Hash + return c +} + +// pbinTestLeafCell carries a storage plain key — the widest a cell holds. +func pbinTestLeafCell(pattern byte, bitLen int16) pbinCell { + c := pbinTestBranchCell(pattern, bitLen) + c.kind = pbinNodeLeaf + for i := range c.storageAddr { + c.storageAddr[i] = pattern + byte(i) + } + c.storageAddrLen = length.Addr + length.Hash + return c +} + +// pbinTestChunkLeafCell is the one leaf shape carrying its value in the record +// instead of a plain key: a code chunk. +func pbinTestChunkLeafCell(pattern byte, bitLen int16) pbinCell { + c := pbinTestBranchCell(pattern, bitLen) + c.kind = pbinNodeLeaf + for i := range c.Storage { + c.Storage[i] = pattern ^ byte(i+1) + } + c.Flags, c.StorageLen = StorageUpdate, pbinValueLength + return c +} + +// The 66-byte storage path does not fit the shared codec's fields, so every +// admissible bit length is checked: a silent truncation commits a wrong root. +func TestPBinBranchCodecRoundTripPrefixBitLengths(t *testing.T) { + t.Parallel() + + var enc pbinBranchEncoder + for bitLen := int16(0); bitLen <= pbinMaxPathBits; bitLen++ { + cells := [2]pbinCell{ + pbinTestBranchCell(0xA5, bitLen), + pbinTestLeafCell(0x5A, pbinMaxPathBits-bitLen), + } + + rec, err := enc.encode(0b11, 0b11, &cells) + require.NoErrorf(t, err, "bitLen %d", bitLen) + + var got [2]pbinCell + touchMap, afterMap, err := pbinDecodeBranch(bytes.Clone(rec), &got) + require.NoErrorf(t, err, "bitLen %d", bitLen) + require.Equal(t, uint16(0b11), touchMap) + require.Equal(t, uint16(0b11), afterMap) + require.Equalf(t, cells, got, "bitLen %d", bitLen) + } +} + +func TestPBinBranchCodecRoundTripCellShapes(t *testing.T) { + t.Parallel() + + accountLeaf := pbinTestEmptyCell() + accountLeaf.kind = pbinNodeLeaf + accountLeaf.prefix = pbinPathFromBits(bytes.Repeat([]byte{0x11}, 66), 17) + copy(accountLeaf.accountAddr[:], bytes.Repeat([]byte{0x42}, length.Addr)) + accountLeaf.accountAddrLen = length.Addr + + for _, tc := range []struct { + name string + touchMap uint16 + afterMap uint16 + cells [2]pbinCell + }{ + {"both branches", 0b11, 0b11, [2]pbinCell{pbinTestBranchCell(0x01, 3), pbinTestBranchCell(0x02, 528)}}, + {"leaf and branch", 0b11, 0b11, [2]pbinCell{pbinTestLeafCell(0x03, 271), pbinTestBranchCell(0x04, 5)}}, + {"hashless account leaf", 0b11, 0b11, [2]pbinCell{accountLeaf, pbinTestLeafCell(0x05, 64)}}, + {"only the right cell present", 0b10, 0b10, [2]pbinCell{pbinTestEmptyCell(), pbinTestBranchCell(0x07, 9)}}, + {"deleted left cell", 0b11, 0b10, [2]pbinCell{pbinTestEmptyCell(), pbinTestBranchCell(0x08, 9)}}, + {"record-resident chunk leaf", 0b11, 0b11, [2]pbinCell{pbinTestChunkLeafCell(0x09, 12), pbinTestBranchCell(0x0A, 21)}}, + {"two chunk leaves", 0b11, 0b11, [2]pbinCell{pbinTestChunkLeafCell(0x0B, 0), pbinTestChunkLeafCell(0x0C, 528)}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var enc pbinBranchEncoder + rec, err := enc.encode(tc.touchMap, tc.afterMap, &tc.cells) + require.NoError(t, err) + + var got [2]pbinCell + touchMap, afterMap, err := pbinDecodeBranch(rec, &got) + require.NoError(t, err) + require.Equal(t, tc.touchMap, touchMap) + require.Equal(t, tc.afterMap, afterMap) + require.Equal(t, tc.cells, got) + }) + } +} + +// The record is self-contained by construction: re-encoding what was decoded +// must reproduce the bytes, so no merge-with-previous path can be needed. +func TestPBinBranchCodecIsCanonical(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + cells [2]pbinCell + }{ + {"plain-key leaf and branch", [2]pbinCell{pbinTestLeafCell(0x7C, 33), pbinTestBranchCell(0x3E, 528)}}, + {"chunk leaf and branch", [2]pbinCell{pbinTestChunkLeafCell(0x6D, 33), pbinTestBranchCell(0x3E, 528)}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var enc pbinBranchEncoder + rec, err := enc.encode(0b11, 0b11, &tc.cells) + require.NoError(t, err) + want := bytes.Clone(rec) + + var got [2]pbinCell + _, _, err = pbinDecodeBranch(want, &got) + require.NoError(t, err) + + again, err := enc.encode(0b11, 0b11, &got) + require.NoError(t, err) + require.Equal(t, want, again) + }) + } +} + +// pbinTestRecord assembles a record by hand so decode can be probed with bytes +// the encoder would never emit. +func pbinTestRecord(touchMap, afterMap uint16, bodies ...[]byte) []byte { + rec := make([]byte, 4) + binary.BigEndian.PutUint16(rec, touchMap) + binary.BigEndian.PutUint16(rec[2:], afterMap) + for _, b := range bodies { + rec = append(rec, b...) + } + return rec +} + +// pbinTestCellBody takes the prefix bytes raw, deliberately not derived from the +// bit count, so a test can make the two disagree. +func pbinTestCellBody(fields pbinCellFields, prefixBitLen uint64, prefix []byte, tail ...byte) []byte { + body := []byte{byte(fields)} + body = binary.AppendUvarint(body, prefixBitLen) + body = append(body, prefix...) + return append(body, tail...) +} + +func pbinTestLenAndVal(val []byte) []byte { + return append(binary.AppendUvarint(nil, uint64(len(val))), val...) +} + +// A declared bit count that disagrees with the bytes behind it must be rejected, +// not read as a shorter or longer prefix: the prefix is inside the branch hash, +// so spurious pad bits silently change the root. +func TestPBinBranchDecodeRejects(t *testing.T) { + t.Parallel() + + body := pbinTestCellBody(pbinFieldBranch, 8, []byte{0xFF}) + + for _, tc := range []struct { + name string + rec []byte + }{ + {"truncated header", []byte{0x00, 0x03, 0x00}}, + {"cell bit outside the arity", pbinTestRecord(0b100, 0b100, body)}, + {"touched bit outside the arity", pbinTestRecord(0b1011, 0b11, body, body)}, + {"missing cell body", pbinTestRecord(0b11, 0b11, body)}, + {"unknown field bit", pbinTestRecord(0b01, 0b01, pbinTestCellBody(0x80, 0, nil))}, + {"no node kind", pbinTestRecord(0b01, 0b01, pbinTestCellBody(0, 0, nil))}, + {"both node kinds", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch|pbinFieldLeaf, 0, nil))}, + {"prefix shorter than its bit count", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch, 16, []byte{0xFF}))}, + {"prefix longer than its bit count", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch, 8, []byte{0xFF, 0xFF}))}, + {"non-zero pad bits", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch, 3, []byte{0xFF}))}, + {"bit count beyond the longest path", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch, pbinMaxPathBits+1, bytes.Repeat([]byte{0xFF}, 67)))}, + {"truncated uvarint", pbinTestRecord(0b01, 0b01, []byte{byte(pbinFieldBranch), 0x80})}, + {"hash longer than a digest", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch|pbinFieldHash, 0, nil, pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, 33))...))}, + {"truncated hash", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch|pbinFieldHash, 0, nil, 32, 0xEE))}, + {"account address of the wrong length", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf|pbinFieldAccountAddr, 0, nil, pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, 21))...))}, + {"storage address of the wrong length", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf|pbinFieldStorageAddr, 0, nil, pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, 51))...))}, + {"trailing bytes", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch, 0, nil), []byte{0x00})}, + // A leaf resolves its value through its plain key, so one without a plain + // key would hash a zero-valued state instead of failing. + {"leaf without a plain key", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf, 0, nil))}, + {"leaf naming both plain keys", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf|pbinFieldAccountAddr|pbinFieldStorageAddr, 0, nil, + append(pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, length.Addr)), pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, length.Addr+length.Hash))...)...))}, + // A record-resident value and a plain key are two answers to the same + // question; a branch has no value at all. + {"leaf naming a plain key and a record value", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf|pbinFieldAccountAddr|pbinFieldLeafValue, 0, nil, + append(pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, length.Addr)), pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, pbinValueLength))...)...))}, + {"branch carrying a record value", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldBranch|pbinFieldLeafValue, 0, nil, + pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, pbinValueLength))...))}, + {"record value shorter than a leaf value", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf|pbinFieldLeafValue, 0, nil, + pbinTestLenAndVal(bytes.Repeat([]byte{0xEE}, pbinValueLength-1))...))}, + {"truncated record value", pbinTestRecord(0b01, 0b01, pbinTestCellBody(pbinFieldLeaf|pbinFieldLeafValue, 0, nil, pbinValueLength, 0xEE))}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var cells [2]pbinCell + _, _, err := pbinDecodeBranch(tc.rec, &cells) + require.Error(t, err) + }) + } +} + +func TestPBinBranchEncodeRejects(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + touchMap uint16 + afterMap uint16 + cells [2]pbinCell + }{ + {"cell bit outside the arity", 0b100, 0b100, [2]pbinCell{}}, + {"touched bit outside the arity", 0b1011, 0b11, [2]pbinCell{pbinTestBranchCell(1, 1), pbinTestBranchCell(2, 1)}}, + {"present cell with no node kind", 0b01, 0b01, [2]pbinCell{}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var enc pbinBranchEncoder + _, err := enc.encode(tc.touchMap, tc.afterMap, &tc.cells) + require.Error(t, err) + }) + } +} + +// A record carries keys and hashes, never state, so a decoded cell must come +// back unloaded no matter what the encoder was handed. +func TestPBinBranchCodecDropsLoadedState(t *testing.T) { + t.Parallel() + + cells := [2]pbinCell{pbinTestLeafCell(0x2B, 40), pbinTestBranchCell(0x4D, 8)} + cells[0].loaded = cellLoadStorage + cells[0].Nonce = 9 + cells[0].Flags = NonceUpdate + + var enc pbinBranchEncoder + rec, err := enc.encode(0b11, 0b11, &cells) + require.NoError(t, err) + + var got [2]pbinCell + _, _, err = pbinDecodeBranch(bytes.Clone(rec), &got) + require.NoError(t, err) + require.Equal(t, cellLoadNone, got[0].loaded) + require.Zero(t, got[0].Nonce) + require.Zero(t, got[0].Flags) +} + +// Decoding into a reused cell must not leave any bits of the previous prefix +// behind — a stale bitLen would extend the new prefix with foreign bits. +func TestPBinBranchDecodeClearsReusedCells(t *testing.T) { + t.Parallel() + + cells := [2]pbinCell{pbinTestLeafCell(0xFF, 528), pbinTestLeafCell(0xFF, 528)} + want := [2]pbinCell{pbinTestBranchCell(0x0F, 3), pbinTestBranchCell(0xF0, 0)} + + var enc pbinBranchEncoder + rec, err := enc.encode(0b11, 0b11, &want) + require.NoError(t, err) + + _, _, err = pbinDecodeBranch(bytes.Clone(rec), &cells) + require.NoError(t, err) + require.Equal(t, want, cells) +} + +func TestPBinCellReset(t *testing.T) { + t.Parallel() + + c := pbinTestLeafCell(0xC3, 271) + c.Nonce = 7 + c.Balance.SetUint64(11) + c.Flags = BalanceUpdate | NonceUpdate + + c.reset() + require.Equal(t, int16(0), c.prefix.bitLen) + require.Zero(t, c.prefix.w) + require.Equal(t, empty.CodeHash, c.CodeHash) + require.Equal(t, pbinTestEmptyCell(), c) +} + +func pbinTestFillGrid(g *pbinGrid, rows int) { + g.activeRows = rows + g.root = pbinTestBranchCell(0x99, 5) + for row := range rows { + g.rows[row][0] = pbinTestLeafCell(byte(row), 271) + g.rows[row][1] = pbinTestBranchCell(byte(row), 33) + g.depths[row] = int16(row * 7) + g.branchBefore[row] = true + g.touchMap[row] = 0b11 + g.afterMap[row] = 0b10 + } +} + +func pbinTestRequireRowEmpty(t *testing.T, g *pbinGrid, row int) { + t.Helper() + require.Equal(t, pbinTestEmptyCell(), g.rows[row][0]) + require.Equal(t, pbinTestEmptyCell(), g.rows[row][1]) + require.Zero(t, g.depths[row]) + require.False(t, g.branchBefore[row]) + require.Zero(t, g.touchMap[row]) + require.Zero(t, g.afterMap[row]) +} + +// resetForReuse only has to clear what the finished run left live; rows above +// activeRows are initialized by unfold before anything reads them. +func TestPBinGridResetForReuse(t *testing.T) { + t.Parallel() + + g := new(pbinGrid) + pbinTestFillGrid(g, 3) + stale := g.rows[2][0] + g.activeRows = 2 + g.resetForReuse() + + require.Zero(t, g.activeRows) + require.Equal(t, pbinTestEmptyCell(), g.root) + pbinTestRequireRowEmpty(t, g, 0) + pbinTestRequireRowEmpty(t, g, 1) + require.Equal(t, stale, g.rows[2][0]) +} + +// A row consumes at least the bit it splits on, so 528 rows cover the deepest +// path. +func TestPBinGridBounds(t *testing.T) { + t.Parallel() + + g := new(pbinGrid) + require.Equal(t, pbinMaxPathBits, len(g.rows)) + require.Equal(t, pbinGridRows, len(g.depths)) + require.Equal(t, 2, len(g.rows[0])) +} diff --git a/execution/commitment/pbin_code.go b/execution/commitment/pbin_code.go new file mode 100644 index 00000000000..ae49457a8f9 --- /dev/null +++ b/execution/commitment/pbin_code.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import "fmt" + +// EIP-8297's code embedding (eip:"Code"). +const ( + // pbinChunkDataLen is how much code one chunk holds; byte 0 of the 32-byte + // value carries the PUSHDATA count instead. + pbinChunkDataLen = pbinValueLength - 1 + + pbinPushOffset = 95 + pbinPush1 = pbinPushOffset + 1 + pbinPush32 = pbinPushOffset + 32 +) + +// pbinChunkifyCode splits code into the tree's chunk values (eip:"Code"). The +// PUSHDATA scan runs over the whole code, so residual PUSHDATA carries across +// chunk boundaries. Padding to a multiple of 31 happens before the scan, which +// is what makes a PUSH whose data runs off the end count against the padded tail. +func pbinChunkifyCode(code []byte) [][pbinValueLength]byte { + if len(code) == 0 { + return nil + } + padded := code + if rem := len(code) % pbinChunkDataLen; rem != 0 { + padded = make([]byte, len(code)+pbinChunkDataLen-rem) + copy(padded, code) + } + + // pushdataAt[i] is how many bytes from i on are still PUSHDATA. It runs a whole + // chunk past the code so a PUSH32 on the last byte has room. + pushdataAt := make([]byte, len(padded)+pbinValueLength) + for pos := 0; pos < len(padded); { + var pushdata int + if padded[pos] >= pbinPush1 && padded[pos] <= pbinPush32 { + pushdata = int(padded[pos]) - pbinPushOffset + } + pos++ + for x := range pushdata { + pushdataAt[pos+x] = byte(pushdata - x) + } + pos += pushdata + } + + chunks := make([][pbinValueLength]byte, 0, len(padded)/pbinChunkDataLen) + for pos := 0; pos < len(padded); pos += pbinChunkDataLen { + var chunk [pbinValueLength]byte + chunk[0] = min(pushdataAt[pos], pbinChunkDataLen) + copy(chunk[1:], padded[pos:pos+pbinChunkDataLen]) + chunks = append(chunks, chunk) + } + return chunks +} + +// pbinRecordLeafValue is the value a leaf carries itself rather than deriving +// from state — a code chunk, or a sub-index the embedding reserves. Unlike a +// storage value it is not left-padded into place: a chunk is positional, so a +// short value is an error. +func pbinRecordLeafValue(u *Update) ([pbinValueLength]byte, error) { + if u.StorageLen != pbinValueLength { + return [pbinValueLength]byte{}, fmt.Errorf("%w: record-resident leaf holds %d value bytes, want %d", + errPBinCellHash, u.StorageLen, pbinValueLength) + } + return u.Storage, nil +} diff --git a/execution/commitment/pbin_code_test.go b/execution/commitment/pbin_code_test.go new file mode 100644 index 00000000000..454f70a70e7 --- /dev/null +++ b/execution/commitment/pbin_code_test.go @@ -0,0 +1,399 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "fmt" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/empty" +) + +// TestPBinChunkifyCodeVectors checks chunking against the reference's own +// chunkings of chunk_code (eip:"Code"). +func TestPBinChunkifyCodeVectors(t *testing.T) { + t.Parallel() + v := pbinLoadSpecVectors(t) + require.NotEmpty(t, v.Chunkify) + + for _, tc := range v.Chunkify { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + got := pbinChunkifyCode(pbinMustHex(t, tc.Code)) + require.Len(t, got, len(tc.Chunks)) + for i, want := range tc.Chunks { + require.Equal(t, pbinMustHex(t, want), got[i][:], "chunk %d", i) + } + }) + } +} + +// TestPBinChunkifyCodePushdataStraddlesBoundary covers PUSHDATA that begins in +// one chunk and runs into the next: the later chunk's byte 0 counts bytes pushed +// by an opcode it does not contain, which is what a per-chunk scan gets wrong. +func TestPBinChunkifyCodePushdataStraddlesBoundary(t *testing.T) { + t.Parallel() + + // PUSH32 at offset 30 is the last byte of chunk 0, so its data spans chunks 1 and 2. + code := append(make([]byte, 30), pbinPush32) + code = append(code, bytes.Repeat([]byte{0xEE}, 32)...) + + chunks := pbinChunkifyCode(code) + require.Len(t, chunks, 3) + require.EqualValues(t, 0, chunks[0][0], "chunk 0 starts on an opcode") + require.EqualValues(t, 31, chunks[1][0], "a full chunk of PUSHDATA saturates at 31") + require.EqualValues(t, 1, chunks[2][0], "one PUSHDATA byte carries into chunk 2") +} + +// TestPBinChunkifyCode7702Designator covers the shortest code the tree holds: a +// 23-byte EIP-7702 designator is one chunk, zero-padded to the full data length. +func TestPBinChunkifyCode7702Designator(t *testing.T) { + t.Parallel() + + designator := append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{0xAB}, 20)...) + require.Len(t, designator, 23) + + chunks := pbinChunkifyCode(designator) + require.Len(t, chunks, 1) + require.EqualValues(t, 0, chunks[0][0]) + require.Equal(t, designator, chunks[0][1:1+len(designator)]) + require.Equal(t, make([]byte, pbinChunkDataLen-len(designator)), chunks[0][1+len(designator):], + "the tail is zero-padded, not left uninitialised") +} + +func TestPBinChunkifyCodeEmpty(t *testing.T) { + t.Parallel() + require.Empty(t, pbinChunkifyCode(nil)) + require.Empty(t, pbinChunkifyCode([]byte{})) +} + +// TestPBinChunkifyCodeChunkCount pins the sizing the code grouping rests on: +// chunks are ceil(len/31), and MaxCodeSize needs more of them than the 256 one +// code group holds. +func TestPBinChunkifyCodeChunkCount(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ size, chunks int }{ + {size: 1, chunks: 1}, + {size: 31, chunks: 1}, + {size: 32, chunks: 2}, + {size: pbinStemSubtreeWidth * pbinChunkDataLen, chunks: pbinStemSubtreeWidth}, + {size: pbinStemSubtreeWidth*pbinChunkDataLen + 1, chunks: pbinStemSubtreeWidth + 1}, + {size: 24576, chunks: 793}, + } { + require.Len(t, pbinChunkifyCode(make([]byte, tc.size)), tc.chunks, "code of %d bytes", tc.size) + } +} + +// pbinTestCode is deterministic filler of a given length. Every byte is below +// PUSH1, so no chunk carries PUSHDATA, and the fill depends on the length, so +// two different lengths never share a chunk. +func pbinTestCode(n int) []byte { + code := make([]byte, n) + for i := range code { + code[i] = byte(n+i) % pbinPush1 + } + return code +} + +// TestPBinEngineEmitsCodeChunks covers code in the tree: chunks reaching the +// reference leaf set in the content-addressed code zone. +func TestPBinEngineEmitsCodeChunks(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(11) + code := pbinTestCode(200) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 4, 500, code) + + // Non-vacuity: the corpus states the fan-out independently of the engine. + require.Equal(t, 2+7, corpus.leafCount(t), "two header leaves plus ceil(200/31) chunks") + + _, root := corpus.process(t) + require.Equal(t, corpus.oracleRoot(t), root) +} + +// TestPBinCodeChunksFollowHeaderSlots composes one account's code, header slots +// and overflow storage: its leaves span all three zones, and the chunks must +// wait for the walk to leave the account zone. +func TestPBinCodeChunksFollowHeaderSlots(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(12) + corpus := new(pbinTestCorpus). + accountWithCodeBytes(addr, 1, 2, pbinTestCode(200)). + storage(addr, pbinOracleSlot(5), 0x77). + storage(addr, pbinOracleSlot(63), 0x88). + storage(addr, pbinOracleSlot(1000), 0x99) + + _, root := corpus.process(t) + require.Equal(t, corpus.oracleRoot(t), root) +} + +// TestPBinVisitOrderIsMonotonic pins the rule behind the emit order: the grid +// only walks forward, so revisiting a key already left behind is a bug in the +// caller's ordering, not something the fold can absorb. +func TestPBinVisitOrderIsMonotonic(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + addr := pbinOracleAddr(13) + u := Update{Flags: NonceUpdate} + + require.NoError(t, pph.followAndUpdate(pbinTreeKeyAccount(addr, pbinCodeHashLeafKey), addr, &u)) + err := pph.followAndUpdate(pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), addr, &u) + require.ErrorIs(t, err, errPBinVisitOrder) +} + +// TestPBinCodeChunksSurviveAsRecordSiblings pins that a chunk leaf carries its +// own value: no state domain holds a chunk, so when a later batch writes into +// the code zone next to an earlier contract's chunks, those chunks have to hash +// from the branch records alone. +func TestPBinCodeChunksSurviveAsRecordSiblings(t *testing.T) { + t.Parallel() + + first := new(pbinTestCorpus).accountWithCodeBytes(pbinOracleAddr(14), 1, 10, pbinTestCode(62)) + second := new(pbinTestCorpus).accountWithCodeBytes(pbinOracleAddr(24), 1, 20, pbinTestCode(93)) + + _, _, root := pbinTestBatches(t, first, second) + require.Equal(t, pbinTestUnion(first, second).oracleRoot(t), root) +} + +// TestPBinRedeployKeepsOldCodeChunks pins the residue a redeploy leaves: chunk +// keys derive from the code hash, so new code names a disjoint leaf set and +// EIP-8297 removes nothing here. A recompute from the state domains cannot know +// the old chunks exist, which is what makes it invalid as an oracle for a +// code-bearing account. +func TestPBinRedeployKeepsOldCodeChunks(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ before, after int }{ + {before: 62, after: 31}, + {before: 200, after: 62}, + {before: 31, after: 62}, // growth keeps the residue too: the old hash names other leaves + } { + t.Run(fmt.Sprintf("%d bytes to %d", tc.before, tc.after), func(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(15) + old, next := pbinTestCode(tc.before), pbinTestCode(tc.after) + deploy := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, old) + redeploy := new(pbinTestCorpus).accountWithCodeBytes(addr, 2, 10, next) + + _, _, forward := pbinTestBatches(t, deploy, redeploy) + + _, rebuilt := new(pbinTestCorpus).accountWithCodeBytes(addr, 2, 10, next).process(t) + require.NotEqual(t, rebuilt, forward, + "a rebuild from state cannot reproduce the stale chunks the forward run kept") + + want := redeploy.entries(t) + oldHash := keccak.Sum256(old) + for i, chunk := range pbinChunkifyCode(old) { + want = append(want, pbinOracleEntry{key: pbinTreeKeyCodeChunk(oldHash, i), value: chunk[:]}) + } + wantRoot := pbinOracleRoot(want) + require.Equal(t, wantRoot[:], forward) + }) + } +} + +// TestPBinClearedCodeKeepsChunks takes the same residue down to zero chunks: +// clearing an account's code, as an EIP-7702 delegation reset does, moves the +// header leaves and leaves every chunk behind. +func TestPBinClearedCodeKeepsChunks(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(19) + designator := pbinTestCode(23) // the size a 7702 designator occupies + deploy := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, designator) + cleared := new(pbinTestCorpus).account(addr, 2, 10, empty.CodeHash) + + _, _, forward := pbinTestBatches(t, deploy, cleared) + + want := cleared.entries(t) + desigHash := keccak.Sum256(designator) + for i, chunk := range pbinChunkifyCode(designator) { + want = append(want, pbinOracleEntry{key: pbinTreeKeyCodeChunk(desigHash, i), value: chunk[:]}) + } + wantRoot := pbinOracleRoot(want) + require.Equal(t, wantRoot[:], forward, "clearing code leaves its chunks in the tree") + + _, rebuilt := new(pbinTestCorpus).account(addr, 2, 10, empty.CodeHash).process(t) + require.NotEqual(t, rebuilt, forward, "the state a rebuild reads no longer names the chunks") +} + +// TestPBinZeroChunkEmitsNoLeaf pins the absence rule for chunks: a chunk is +// absent only when its whole 32-byte value is zero — 31 zero code bytes and a +// zero PUSHDATA count. The same zero bytes continuing an earlier chunk's PUSH +// keep their leaf, and code_size delimits the code either way. +func TestPBinZeroChunkEmitsNoLeaf(t *testing.T) { + t.Parallel() + + opcodes := pbinTestCode(31) // every byte below PUSH1, none zero + + t.Run("zero tail chunk is absent", func(t *testing.T) { + t.Parallel() + + code := append(bytes.Clone(opcodes), make([]byte, 31)...) + chunks := pbinChunkifyCode(code) + require.Len(t, chunks, 2) + require.Equal(t, [pbinValueLength]byte{}, chunks[1]) + + corpus := new(pbinTestCorpus).accountWithCodeBytes(pbinOracleAddr(25), 1, 10, code) + require.Equal(t, 2+1, corpus.leafCount(t), "the zero chunk contributes no leaf") + + _, root := corpus.process(t) + require.Equal(t, corpus.oracleRoot(t), root) + }) + + t.Run("pushdata continuation keeps the leaf", func(t *testing.T) { + t.Parallel() + + code := append(bytes.Clone(opcodes[:30]), byte(pbinPushOffset+31)) + code = append(code, make([]byte, 31)...) + chunks := pbinChunkifyCode(code) + require.Len(t, chunks, 2) + require.EqualValues(t, 31, chunks[1][0], "byte 0 counts the PUSH31 data") + + corpus := new(pbinTestCorpus).accountWithCodeBytes(pbinOracleAddr(26), 1, 10, code) + require.Equal(t, 2+2, corpus.leafCount(t), "the continuation chunk keeps its leaf") + + _, root := corpus.process(t) + require.Equal(t, corpus.oracleRoot(t), root) + }) +} + +func TestPBinCodelessContextRefusesCodeBearingAccount(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(17) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, pbinTestCode(62)) + + ms := NewMockState(t) + corpus.applyTo(t, ms) + pph := NewPBinPatriciaHashed(pbinCodelessContext{ms}) + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), corpus.plainKeys, corpus.updates) + + _, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.ErrorIs(t, err, ErrPBinUnsupported) +} + +// pbinCodelessContext hides the concrete state's code read: code is served +// through an optional interface, so embedding PatriciaContext rather than the +// state makes that assertion fail. +type pbinCodelessContext struct{ PatriciaContext } + +// TestPBinCodeSizeMustMatchTheCodeBehindIt pins that the two reads agree: the +// BASIC_DATA size and the chunks come from separate reads, so a size that +// disagrees with the code would commit a leaf set no reference tree holds. +func TestPBinCodeSizeMustMatchTheCodeBehindIt(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(18) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, pbinTestCode(62)) + + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + ms.setCode(addr, pbinTestCode(31)) // the account still says 62 bytes + + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), corpus.plainKeys, corpus.updates) + _, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.ErrorContains(t, err, "the code domain holds") +} + +// TestPBinZoneKeyLengthIsExplicit pins that the zone byte decides the key +// length: an account key and a code key are both 34 bytes, so a code key would +// otherwise pass as an account one. Unallocated zones are refused. +func TestPBinZoneKeyLengthIsExplicit(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + zone byte + want int + known bool + }{ + {zone: pbinAccountZone, want: pbinAccountKeyLength, known: true}, + {zone: pbinCodeZone, want: pbinCodeKeyLength, known: true}, + {zone: pbinStorageZone, want: pbinStorageKeyLength, known: true}, + {zone: 0x02}, {zone: 0x7F}, {zone: 0xFE}, + } { + got, known := pbinZoneKeyLength(tc.zone) + require.Equal(t, tc.known, known, "zone %#x", tc.zone) + require.Equal(t, tc.want, got, "zone %#x", tc.zone) + } + + require.Panics(t, func() { pbinTreeKey(0x02, make([]byte, 32), 0) }, "an unallocated zone has no key length") + require.Len(t, pbinTreeKey(pbinCodeZone, make([]byte, 32), 0), pbinCodeKeyLength) +} + +// TestPBinLeafValueRoutesByZone covers the same rule at the value encoder: the +// leaf value is picked by the key's zone, so a code-zone key must not be read as +// an account header sub-index. +func TestPBinLeafValueRoutesByZone(t *testing.T) { + t.Parallel() + + chunk := pbinChunkifyCode(pbinTestCode(31))[0] + u := Update{Flags: StorageUpdate, StorageLen: pbinValueLength} + copy(u.Storage[:], chunk[:]) + + // A code-zone key at sub-index 0 would be BASIC_DATA if the zone were ignored. + got, err := pbinLeafValue(pbinTreeKey(pbinCodeZone, make([]byte, 32), 0), &u) + require.NoError(t, err) + require.Equal(t, chunk[:], got[:]) + + // Inside the account zone, sub-indices past the header storage span are + // reserved and carry their value verbatim, not as storage. + got, err = pbinLeafValue(pbinTreeKey(pbinAccountZone, make([]byte, 32), pbinHeaderStorageOffset+pbinHeaderStorageSlots), &u) + require.NoError(t, err) + require.Equal(t, chunk[:], got[:]) + + // A chunk leaf holding fewer than 32 value bytes cannot be left-padded into + // place the way a storage value can: byte 0 is the PUSHDATA count. + short := Update{Flags: StorageUpdate, StorageLen: 4} + _, err = pbinLeafValue(pbinTreeKeyCodeChunk(keccak.Sum256(pbinTestCode(62)), 1), &short) + require.ErrorIs(t, err, errPBinCellHash) +} + +// TestPBinLeafCellHashChecksZoneLength covers the same rule at the leaf hash: a +// 34-byte storage key or a 66-byte code key is rejected instead of hashed. +func TestPBinLeafCellHashChecksZoneLength(t *testing.T) { + t.Parallel() + + var h pbinHasher + u := Update{Flags: StorageUpdate, StorageLen: pbinValueLength} + + for _, tc := range []struct { + name string + key []byte + }{ + {name: "storage zone at account length", key: append([]byte{pbinStorageZone}, make([]byte, pbinAccountKeyLength-1)...)}, + {name: "code zone at storage length", key: append([]byte{pbinCodeZone}, make([]byte, pbinStorageKeyLength-1)...)}, + {name: "unallocated zone", key: append([]byte{0x02}, make([]byte, pbinAccountKeyLength-1)...)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + c := pbinCell{kind: pbinNodeLeaf, prefix: pbinPathFromBytes(tc.key), Update: u} + var path pbinBitpath + _, err := h.cellHash(&c, &path) + require.ErrorIs(t, err, errPBinCellHash) + }) + } +} diff --git a/execution/commitment/pbin_codesize_test.go b/execution/commitment/pbin_codesize_test.go new file mode 100644 index 00000000000..1015498a8d3 --- /dev/null +++ b/execution/commitment/pbin_codesize_test.go @@ -0,0 +1,132 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// TestPBinBasicDataLeafCarriesCodeSize checks the BASIC_DATA packing against the +// reference's own vectors: the code size has to reach the leaf value. +func TestPBinBasicDataLeafCarriesCodeSize(t *testing.T) { + t.Parallel() + v := pbinLoadSpecVectors(t) + require.NotEmpty(t, v.BasicData) + + addr := pbinOracleAddr(1) + key := pbinTreeKeyAccount(addr, pbinBasicDataLeafKey) + + for _, tc := range v.BasicData { + bal, err := uint256.FromDecimal(tc.Balance) + require.NoError(t, err) + + u := Update{Flags: NonceUpdate | BalanceUpdate, Nonce: tc.Nonce, Balance: *bal, CodeSize: tc.CodeSize} + got, err := pbinLeafValue(key, &u) + require.NoError(t, err) + require.Equal(t, pbinMustHex(t, tc.Value), got[:], + "BASIC_DATA leaf mismatch for code_size=%d nonce=%d balance=%s", tc.CodeSize, tc.Nonce, tc.Balance) + } +} + +// TestPBinEngineRootCarriesCodeSize drives a code-bearing account through the +// whole engine, so the size has to survive the context read, the cell merge and +// the leaf hash, not just the value encoder. +func TestPBinEngineRootCarriesCodeSize(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(9) + code := pbinTestCode(1000) + withCode := new(pbinTestCorpus).accountWithCodeBytes(addr, 4, 500, code) + + _, root := withCode.process(t) + require.Equal(t, withCode.oracleRoot(t), root) + + // Repacking BASIC_DATA at code_size 0 isolates the size: every other leaf, + // the chunks included, stays where it was. + sizeless, err := pbinEncodeBasicData(4, uint256.NewInt(500), 0) + require.NoError(t, err) + entries := withCode.entries(t) + basicDataKey := pbinTreeKeyAccount(addr, pbinBasicDataLeafKey) + patched := 0 + for i := range entries { + if bytes.Equal(entries[i].key, basicDataKey) { + entries[i].value, patched = sizeless[:], patched+1 + } + } + require.Equal(t, 1, patched) + + want := pbinOracleRoot(entries) + require.NotEqual(t, want[:], root, "code_size must reach the root") +} + +// TestPBinUpdateCodeSizeSurvivesCopyAndReset pins the Update lifecycle: Copy +// keeps the size, Reset drops it so a pooled cell cannot inherit a stale one. +func TestPBinUpdateCodeSizeSurvivesCopyAndReset(t *testing.T) { + t.Parallel() + + u := Update{Flags: CodeUpdate, CodeHash: common.Hash{0x01}, CodeSize: 24576} + require.Equal(t, uint64(24576), u.Copy().CodeSize) + + u.Reset() + require.Zero(t, u.CodeSize) +} + +// TestPBinUpdateCodeSizeMergesWithCodeHash pins that size and hash travel +// together: they describe the same code, so a merge must never take one from the +// old account and the other from the new. +func TestPBinUpdateCodeSizeMergesWithCodeHash(t *testing.T) { + t.Parallel() + + dst := Update{Flags: CodeUpdate, CodeHash: common.Hash{0x01}, CodeSize: 100} + dst.Merge(&Update{Flags: CodeUpdate, CodeHash: common.Hash{0x02}, CodeSize: 200}) + require.Equal(t, common.Hash{0x02}, dst.CodeHash) + require.Equal(t, uint64(200), dst.CodeSize) +} + +// TestPBinPushSideNeverDeliversCode pins that Updates.TouchCode cannot feed the +// bin trie: the variant is hardwired to ModeDirect, which interns plain keys only +// and hands the trie a nil update. Everything the tree hashes comes from the read +// side, so teaching the push side to carry code would add dead code. +func TestPBinPushSideNeverDeliversCode(t *testing.T) { + t.Parallel() + + cfg := DefaultTrieConfig() + cfg.Variant = VariantBinPatriciaTrie + trie, upd := InitializeTrieAndUpdates(ModeUpdate, t.TempDir(), cfg) + defer upd.Close() + + require.IsType(t, &PBinPatriciaHashed{}, trie) + require.Equal(t, ModeDirect, upd.Mode(), "the bin variant overrides the requested mode") + + addr := pbinOracleAddr(3) + upd.TouchPlainKey(string(addr), []byte{0x60, 0x00, 0x60, 0x00}, upd.TouchCode) + + keys := 0 + require.NoError(t, upd.HashSort(context.Background(), nil, func(treeKey, plainKey []byte, u *Update) error { + keys++ + require.Nil(t, u, "ModeDirect delivers no update, so TouchCode cannot reach the trie") + return nil + })) + require.Equal(t, 1, keys) +} diff --git a/execution/commitment/pbin_conformance_test.go b/execution/commitment/pbin_conformance_test.go new file mode 100644 index 00000000000..8f4a4ea9f94 --- /dev/null +++ b/execution/commitment/pbin_conformance_test.go @@ -0,0 +1,277 @@ +package commitment + +// The cross-client conformance vectors from ethereum/execution-specs +// (projects/binary-trie), vendored verbatim as testdata/binary_trie_vectors.json +// and regenerated there by the reference implementation, which hashes with +// BLAKE3. +// +// The four primitive sections pin the embedding piece by piece; pbt_state pins +// their composition — whole accounts to a root, which is where an embedding +// mistake actually surfaces. + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "math/big" + "os" + "sort" + "strconv" + "strings" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +type pbinConformance struct { + Source string `json:"source"` + SourceCommit string `json:"source_commit"` + + TrieRoots []pbinSpecTrieVector `json:"trie_roots"` + + Embedding struct { + Address20 string `json:"address20"` + Address32 string `json:"address32"` + BasicDataKey string `json:"basic_data_key"` + CodeHashKey string `json:"code_hash_key"` + DelegationKey string `json:"delegation_key"` + StorageSlotKeys map[string]string `json:"storage_slot_keys"` + CodeChunkKeys map[string]string `json:"code_chunk_keys"` + CodeHash string `json:"code_hash"` + } `json:"embedding"` + + ChunkifyCode []struct { + Name string `json:"name"` + Code string `json:"code"` + Chunks []string `json:"chunks"` + } `json:"chunkify_code"` + + EncodeBasicData []struct { + CodeSize uint64 `json:"code_size"` + Nonce uint64 `json:"nonce"` + Balance string `json:"balance"` + Encoded string `json:"encoded"` + } `json:"encode_basic_data"` + + PBTState []struct { + Name string `json:"name"` + Accounts map[string]struct { + Nonce uint64 `json:"nonce"` + Balance string `json:"balance"` + Code string `json:"code"` + CodeHash string `json:"code_hash"` + Storage map[string]string `json:"storage"` + } `json:"accounts"` + Root string `json:"root"` + } `json:"pbt_state"` +} + +func pbinLoadConformance(t *testing.T) *pbinConformance { + t.Helper() + raw, err := os.ReadFile("testdata/binary_trie_vectors.json") + require.NoError(t, err) + v := new(pbinConformance) + require.NoError(t, json.Unmarshal(raw, v)) + require.NotEmpty(t, v.SourceCommit) + return v +} + +func pbinUnhex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + require.NoError(t, err) + return b +} + +// pbinSlotBytes parses a slot given as a full decimal expansion, which reaches +// 2**256-1 and so cannot go through a JSON number. +func pbinSlotBytes(t *testing.T, decimal string) []byte { + t.Helper() + n, ok := new(big.Int).SetString(decimal, 10) + require.True(t, ok, "slot %q", decimal) + var slot [32]byte + n.FillBytes(slot[:]) + return slot[:] +} + +// TestPBinConformanceTrieRoots pins raw trie semantics against the oracle. The +// engine cannot take these: their keys carry synthetic zone bytes chosen to +// exercise bit-level divergence, and the engine only admits allocated zones. +// TestPBinConformancePBTState is where the engine meets the same reference. +func TestPBinConformanceTrieRoots(t *testing.T) { + for _, c := range pbinLoadConformance(t).TrieRoots { + t.Run(c.Name, func(t *testing.T) { + tree := &pbinOracleTree{} + for _, e := range c.Entries { + tree.insert(pbinUnhex(t, e.Key), pbinUnhex(t, e.Value)) + } + got := pbinOracleMerkelizeWith(tree.root, pbinBlake3Sum) + require.Equal(t, c.Root, "0x"+hex.EncodeToString(got[:])) + }) + } +} + +func TestPBinConformanceEmbedding(t *testing.T) { + e := pbinLoadConformance(t).Embedding + addr := pbinUnhex(t, e.Address20) + codeHash := common.BytesToHash(pbinUnhex(t, e.CodeHash)) + keys := pbinDigestCache{sum: pbinBlake3Hash} + + require.Equal(t, e.Address32, "0x"+hex.EncodeToString(func() []byte { + a := pbinAddr32(addr) + return a[:] + }())) + + hexKey := func(k []byte) string { return "0x" + hex.EncodeToString(k) } + require.Equal(t, e.BasicDataKey, hexKey(keys.accountKey(addr, pbinBasicDataLeafKey))) + require.Equal(t, e.CodeHashKey, hexKey(keys.accountKey(addr, pbinCodeHashLeafKey))) + require.Equal(t, e.DelegationKey, hexKey(keys.accountKey(addr, pbinDelegationLeafKey))) + + for slot, want := range e.StorageSlotKeys { + require.Equal(t, want, hexKey(keys.storageKey(addr, pbinSlotBytes(t, slot))), "slot %s", slot) + } + + for chunk, want := range e.CodeChunkKeys { + id, err := strconv.Atoi(chunk) + require.NoError(t, err) + require.Equal(t, want, hexKey(keys.codeChunkKey(codeHash, id)), "chunk %s", chunk) + } +} + +func TestPBinConformanceChunkifyCode(t *testing.T) { + for _, c := range pbinLoadConformance(t).ChunkifyCode { + t.Run(c.Name, func(t *testing.T) { + chunks := pbinChunkifyCode(pbinUnhex(t, c.Code)) + require.Len(t, chunks, len(c.Chunks)) + for i, want := range c.Chunks { + require.Equal(t, want, "0x"+hex.EncodeToString(chunks[i][:]), "chunk %d", i) + } + }) + } +} + +func TestPBinConformanceEncodeBasicData(t *testing.T) { + for _, c := range pbinLoadConformance(t).EncodeBasicData { + balance, err := uint256.FromHex(c.Balance) + require.NoError(t, err) + got, err := pbinEncodeBasicData(c.Nonce, balance, c.CodeSize) + require.NoError(t, err) + require.Equal(t, c.Encoded, "0x"+hex.EncodeToString(got[:]), + "code_size=%d nonce=%d balance=%s", c.CodeSize, c.Nonce, c.Balance) + } +} + +// TestPBinConformancePBTState rebuilds each reference state leaf by leaf and +// checks the root, through the oracle and through the engine. Two rules decide +// what is not written: a leaf whose value is 32 zero bytes is absent, and code +// length comes from code_size rather than from which chunks exist. +func TestPBinConformancePBTState(t *testing.T) { + pbinRestoreHashSuite(t) + require.NoError(t, SetPBinHashSuite(PBinHashBlake3)) + + var zero [pbinValueLength]byte + for _, c := range pbinLoadConformance(t).PBTState { + t.Run(c.Name, func(t *testing.T) { + keys := pbinDigestCache{sum: pbinBlake3Hash} + leaves := map[string][]byte{} + put := func(key []byte, value [pbinValueLength]byte) { + if value == zero { + return + } + leaves[string(key)] = value[:] + } + + for addrHex, acc := range c.Accounts { + addr := pbinUnhex(t, addrHex) + code := pbinUnhex(t, acc.Code) + codeHash := common.BytesToHash(pbinUnhex(t, acc.CodeHash)) + balance, err := uint256.FromHex(acc.Balance) + require.NoError(t, err) + + basic, err := pbinEncodeBasicData(acc.Nonce, balance, uint64(len(code))) + require.NoError(t, err) + put(keys.accountKey(addr, pbinBasicDataLeafKey), basic) + if pbinIsDelegation(code) { + put(keys.accountKey(addr, pbinDelegationLeafKey), pbinEncodeDelegation(code)) + } else { + put(keys.accountKey(addr, pbinCodeHashLeafKey), pbinCodeHashValue(codeHash)) + for i, chunk := range pbinChunkifyCode(code) { + put(keys.codeChunkKey(codeHash, i), chunk) + } + } + + for slot, value := range acc.Storage { + put(keys.storageKey(addr, pbinSlotBytes(t, slot)), + pbinEncodeStorageValue(pbinUnhex(t, value))) + } + } + + ordered := make([]string, 0, len(leaves)) + for k := range leaves { + ordered = append(ordered, k) + } + sort.Strings(ordered) + + tree := &pbinOracleTree{} + for _, k := range ordered { + tree.insert([]byte(k), leaves[k]) + } + got := pbinOracleMerkelizeWith(tree.root, pbinBlake3Sum) + require.Equal(t, c.Root, "0x"+hex.EncodeToString(got[:]), "oracle, %d leaves", len(leaves)) + + if len(leaves) == 0 { + return // the engine needs a context to load a root it never stored + } + // The engine derives chunk and header leaves itself from an account + // update, so it is driven by accounts and slots rather than by the leaf + // set above — that is the point of running both. + corpus := &pbinTestCorpus{codes: map[string][]byte{}} + for addrHex, acc := range c.Accounts { + addr := pbinUnhex(t, addrHex) + code := pbinUnhex(t, acc.Code) + balance, err := uint256.FromHex(acc.Balance) + require.NoError(t, err) + u := Update{ + Flags: NonceUpdate | BalanceUpdate | CodeUpdate, + Nonce: acc.Nonce, + CodeHash: common.BytesToHash(pbinUnhex(t, acc.CodeHash)), + CodeSize: uint64(len(code)), + } + u.Balance.Set(balance) + corpus.plainKeys = append(corpus.plainKeys, addr) + corpus.updates = append(corpus.updates, u) + corpus.codes[string(addr)] = code + + for slot, value := range acc.Storage { + trimmed := pbinTrimLeft(pbinUnhex(t, value)) + su := Update{Flags: StorageUpdate, StorageLen: int8(len(trimmed))} + copy(su.Storage[:], trimmed) + corpus.plainKeys = append(corpus.plainKeys, append(bytes.Clone(addr), pbinSlotBytes(t, slot)...)) + corpus.updates = append(corpus.updates, su) + } + } + + pph, ms := pbinTestEngine(t) + hasher := pph.setHashSuite(pbinBlake3Hash) + corpus.applyTo(t, ms) + upd := WrapKeyUpdates(t, ModeDirect, hasher, corpus.plainKeys, corpus.updates) + root, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.NoError(t, err) + require.Equal(t, c.Root, "0x"+hex.EncodeToString(root), "engine") + }) + } +} + +// pbinTrimLeft drops leading zero bytes, the trimmed form the domain layer keeps +// a storage value in. +func pbinTrimLeft(value []byte) []byte { + i := 0 + for i < len(value) && value[i] == 0 { + i++ + } + return value[i:] +} diff --git a/execution/commitment/pbin_delegation_test.go b/execution/commitment/pbin_delegation_test.go new file mode 100644 index 00000000000..375303d571f --- /dev/null +++ b/execution/commitment/pbin_delegation_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/empty" +) + +// TestPBinIsDelegationClassifiesByBytes pins that classification reads the code +// bytes and nothing else: 23 bytes opening with the marker. Code whose keccak +// hash begins with the marker is still code. +func TestPBinIsDelegationClassifiesByBytes(t *testing.T) { + t.Parallel() + + marker := []byte{0xEF, 0x01, 0x00} + indicator := append(bytes.Clone(marker), bytes.Repeat([]byte{0xAB}, 20)...) + require.True(t, pbinIsDelegation(indicator)) + + hashGrindsToMarker := pbinMustHex(t, "0x0000000000000000000000000000000000000000637401") + require.Len(t, hashGrindsToMarker, pbinDelegationCodeLength) + h := keccak.Sum256(hashGrindsToMarker) + require.Equal(t, marker, h[:3], "the ground value must still hash to the marker") + require.False(t, pbinIsDelegation(hashGrindsToMarker)) + + require.False(t, pbinIsDelegation(append(bytes.Clone(marker), bytes.Repeat([]byte{0xAB}, 19)...))) + require.False(t, pbinIsDelegation(append(bytes.Clone(marker), bytes.Repeat([]byte{0xAB}, 21)...))) + require.False(t, pbinIsDelegation(marker)) + require.False(t, pbinIsDelegation(nil)) +} + +func TestPBinEncodeDelegationPadsToThirtyTwo(t *testing.T) { + t.Parallel() + + indicator := append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{0xCD}, 20)...) + v := pbinEncodeDelegation(indicator) + require.Equal(t, indicator, v[:pbinDelegationCodeLength]) + require.Equal(t, make([]byte, pbinValueLength-pbinDelegationCodeLength), v[pbinDelegationCodeLength:]) + + chunk := pbinChunkifyCode(indicator)[0] + require.NotEqual(t, chunk, v, + "an indicator is not chunk-encoded: byte 0 carries code, not a PUSHDATA count") +} + +// TestPBinDelegationLeafIsExclusive pins the header rule: an account holds +// exactly one of the CODE_HASH and DELEGATION leaves, decided by its current +// code bytes, and every write removes the other leaf. +func TestPBinDelegationLeafIsExclusive(t *testing.T) { + t.Parallel() + + indicator := append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{0x11}, 20)...) + + t.Run("fresh EOA delegates", func(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(91) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, indicator) + _, root := corpus.process(t) + + basic, err := pbinEncodeBasicData(1, &corpus.updates[0].Balance, pbinDelegationCodeLength) + require.NoError(t, err) + delegation := pbinEncodeDelegation(indicator) + want := pbinOracleRoot([]pbinOracleEntry{ + {key: pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), value: basic[:]}, + {key: pbinTreeKeyAccount(addr, pbinDelegationLeafKey), value: delegation[:]}, + }) + require.Equal(t, want[:], root, "a delegated account is BASIC_DATA plus the indicator: no code-hash leaf, no chunks") + }) + + t.Run("delegation replaces contract code", func(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(92) + code := pbinTestCode(62) + deploy := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, code) + delegate := new(pbinTestCorpus).accountWithCodeBytes(addr, 2, 10, indicator) + _, _, forward := pbinTestBatches(t, deploy, delegate) + + want := delegate.entries(t) + oldHash := keccak.Sum256(code) + for i, chunk := range pbinChunkifyCode(code) { + want = append(want, pbinOracleEntry{key: pbinTreeKeyCodeChunk(oldHash, i), value: chunk[:]}) + } + wantRoot := pbinOracleRoot(want) + require.Equal(t, wantRoot[:], forward, + "the code-hash leaf goes; the old chunks stay, content-addressed by the old hash") + }) + + t.Run("delegation cleared to empty code", func(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(93) + delegate := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 10, indicator) + cleared := new(pbinTestCorpus).account(addr, 2, 10, empty.CodeHash) + _, _, forward := pbinTestBatches(t, delegate, cleared) + + require.Equal(t, cleared.oracleRoot(t), forward, + "clearing restores the empty-code CODE_HASH leaf and removes the indicator") + }) + + t.Run("two authorities one target", func(t *testing.T) { + t.Parallel() + + a, b := pbinOracleAddr(94), pbinOracleAddr(95) + corpus := new(pbinTestCorpus). + accountWithCodeBytes(a, 1, 10, indicator). + accountWithCodeBytes(b, 2, 20, indicator) + _, root := corpus.process(t) + + basicA, err := pbinEncodeBasicData(1, &corpus.updates[0].Balance, pbinDelegationCodeLength) + require.NoError(t, err) + basicB, err := pbinEncodeBasicData(2, &corpus.updates[1].Balance, pbinDelegationCodeLength) + require.NoError(t, err) + delegation := pbinEncodeDelegation(indicator) + want := pbinOracleRoot([]pbinOracleEntry{ + {key: pbinTreeKeyAccount(a, pbinBasicDataLeafKey), value: basicA[:]}, + {key: pbinTreeKeyAccount(a, pbinDelegationLeafKey), value: delegation[:]}, + {key: pbinTreeKeyAccount(b, pbinBasicDataLeafKey), value: basicB[:]}, + {key: pbinTreeKeyAccount(b, pbinDelegationLeafKey), value: delegation[:]}, + }) + require.Equal(t, want[:], root, + "each authority holds its own header leaf; the shared target adds no shared leaf") + }) +} diff --git a/execution/commitment/pbin_domainwrite_test.go b/execution/commitment/pbin_domainwrite_test.go new file mode 100644 index 00000000000..688755e6b06 --- /dev/null +++ b/execution/commitment/pbin_domainwrite_test.go @@ -0,0 +1,229 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// pbinStrictWriteContext mirrors the domain's write contract: SharedDomains +// refuses a nil value outright, so a PutBranch handing one over fails here the +// way it would over a real datadir. +type pbinStrictWriteContext struct { + *MockState + puts []pbinRecordedPut +} + +type pbinRecordedPut struct { + prefix, data, prev []byte +} + +func (c *pbinStrictWriteContext) PutBranch(prefix, data, prevData []byte) error { + if data == nil { + return fmt.Errorf("pbin test: nil value for %x refused, as the domain would", prefix) + } + c.puts = append(c.puts, pbinRecordedPut{bytes.Clone(prefix), bytes.Clone(data), bytes.Clone(prevData)}) + return c.MockState.PutBranch(prefix, data, prevData) +} + +func pbinTestStrictEngine(t *testing.T) (*PBinPatriciaHashed, *pbinStrictWriteContext, *MockState) { + t.Helper() + ms := NewMockState(t) + ctx := &pbinStrictWriteContext{MockState: ms} + return NewPBinPatriciaHashed(ctx), ctx, ms +} + +// An emptied tree deletes its root record by writing a zero-length value, never +// nil. +func TestPBinStoreRootEmptiedTreeWritesNonNil(t *testing.T) { + t.Parallel() + + pph, ctx, _ := pbinTestStrictEngine(t) + require.NoError(t, pph.loadRoot()) + pph.rootTouched = true + + require.NoError(t, pph.storeRoot()) + require.Len(t, ctx.puts, 1) + put := ctx.puts[0] + require.Equal(t, pbinRootKey, put.prefix) + require.NotNil(t, put.data) + require.Empty(t, put.data) + require.NotNil(t, put.prev) +} + +// A deletion write carries a zero-length value and, as prevData, the record +// bytes the row unfolded from — likewise for the root record storeRoot empties. +func TestPBinFoldDeleteWritesNonNilWithRealPrev(t *testing.T) { + t.Parallel() + + pph, ctx, ms := pbinTestStrictEngine(t) + pbinTestPutTopRecord(t, ms, [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "010"), + pbinTestSpecCell(t, pbinNodeLeaf, "110"), + }) + emptyPath := pbinBitpath{} + recordKey := pbinEncodeBitPath(&emptyPath) + storedRecord := bytes.Clone(ms.cm[string(recordKey)]) + storedRoot := bytes.Clone(ms.cm[string(pbinRootKey)]) + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, "0101")) + pbinTestUnfoldStep(t, pph, &probe) + pph.grid.touchMap[0], pph.grid.afterMap[0] = 0b11, 0 + require.NoError(t, pph.fold()) + require.NoError(t, pph.storeRoot()) + + require.Len(t, ctx.puts, 2) + del, root := ctx.puts[0], ctx.puts[1] + require.Equal(t, recordKey, del.prefix) + require.NotNil(t, del.data) + require.Empty(t, del.data) + require.Equal(t, storedRecord, del.prev) + + require.Equal(t, pbinRootKey, root.prefix) + require.NotNil(t, root.data) + require.Empty(t, root.data) + require.Equal(t, storedRoot, root.prev) +} + +// After the engine empties a stored tree, the zero-length records still sitting +// in the store must read back as no tree at all. +func TestPBinZeroLengthBranchRoundTripsAsDeletion(t *testing.T) { + t.Parallel() + + pph, _, ms := pbinTestStrictEngine(t) + pbinTestPutTopRecord(t, ms, [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "010"), + pbinTestSpecCell(t, pbinNodeLeaf, "110"), + }) + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, "0101")) + pbinTestUnfoldStep(t, pph, &probe) + pph.grid.touchMap[0], pph.grid.afterMap[0] = 0b11, 0 + require.NoError(t, pph.fold()) + require.NoError(t, pph.storeRoot()) + + emptyPath := pbinBitpath{} + require.Contains(t, ms.cm, string(pbinEncodeBitPath(&emptyPath))) + require.Contains(t, ms.cm, string(pbinRootKey)) + + fresh := NewPBinPatriciaHashed(ms) + require.NoError(t, fresh.loadRoot()) + require.False(t, fresh.rootPresent, "a zero-length root record must read back as no tree") + root, err := fresh.RootHash() + require.NoError(t, err) + require.Equal(t, make([]byte, length.Hash), root) +} + +// pbinRequirePutsMatchStore replays the recorded writes against the store, +// requiring each prevData to be exactly the value that write replaces — and +// non-nil, so the domain never falls back to its own read. +func pbinRequirePutsMatchStore(t *testing.T, puts []pbinRecordedPut, store map[string][]byte) (overwrites int) { + t.Helper() + for _, put := range puts { + require.NotNil(t, put.prev, "nil prevData at %x forces an extra domain read", put.prefix) + require.True(t, bytes.Equal(store[string(put.prefix)], put.prev), + "prevData at %x does not match the record it replaces", put.prefix) + if len(put.prev) > 0 { + overwrites++ + } + store[string(put.prefix)] = put.data + } + return overwrites +} + +// A removed account takes every record it owned with it. The drop stops the +// unfold at the account's subtree, so no fold reaches what is below and only an +// explicit sweep reclaims it — left behind, those records are unreachable bytes +// no prune collects, and a later rebuild of the same path would report a +// previous value that is not there. +func TestPBinAccountRemovalLeavesNoRecordBehind(t *testing.T) { + t.Parallel() + + keep, gone := pbinOracleAddr(11), pbinOracleAddr(22) + stored := new(pbinTestCorpus). + account(keep, 1, 2, common.Hash{0x11}). + account(gone, 3, 4, common.Hash{0x22}) + for i := range 16 { + stored.storage(gone, pbinOracleSlot(uint64(256+i)), byte(i+1)) + } + + pph, ctx, ms := pbinTestStrictEngine(t) + stored.applyTo(t, ms) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + snapshot := make(map[string][]byte, len(ms.cm)) + for k, v := range ms.cm { + snapshot[k] = bytes.Clone(v) + } + ctx.puts = nil + + removal := new(pbinTestCorpus).remove(gone) + require.NoError(t, ms.applyPlainUpdates(removal.plainKeys, removal.updates)) + pph.Reset() + root := pbinTestProcess(t, pph, removal.plainKeys, removal.updates) + + survivor := new(pbinTestCorpus).account(keep, 1, 2, common.Hash{0x11}) + require.Equal(t, survivor.oracleRoot(t), root) + pbinRequirePutsMatchStore(t, ctx.puts, snapshot) + + _, rebuilt := pbinTestEngine(t) + survivor.applyTo(t, rebuilt) + pbinTestProcess(t, NewPBinPatriciaHashed(rebuilt), survivor.plainKeys, survivor.updates) + require.Equal(t, pbinLiveRecordKeys(rebuilt), pbinLiveRecordKeys(ms), + "the forward run must hold exactly the records a rebuild does") +} + +// Every branch write carries the record it replaces: empty on a fresh store, the +// stored bytes on a rewrite. +func TestPBinProcessPutBranchCarriesRealPrev(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(31) + stored := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02). + account(pbinOracleAddr(32), 3, 4, common.Hash{0x32}) + touch := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x0A). + storage(addr, pbinOracleSlot(258), 0x03) + + pph, ctx, ms := pbinTestStrictEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + require.NotEmpty(t, ctx.puts) + require.Zero(t, pbinRequirePutsMatchStore(t, ctx.puts, map[string][]byte{}), + "the first run has nothing to overwrite") + + snapshot := make(map[string][]byte, len(ms.cm)) + for k, v := range ms.cm { + snapshot[k] = bytes.Clone(v) + } + ctx.puts = nil + + require.NoError(t, ms.applyPlainUpdates(touch.plainKeys, touch.updates)) + pph.Reset() + pbinTestProcess(t, pph, touch.plainKeys, touch.updates) + require.NotZero(t, pbinRequirePutsMatchStore(t, ctx.puts, snapshot), + "the second run must rewrite at least one stored record") +} diff --git a/execution/commitment/pbin_fold_test.go b/execution/commitment/pbin_fold_test.go new file mode 100644 index 00000000000..bf7278f31b3 --- /dev/null +++ b/execution/commitment/pbin_fold_test.go @@ -0,0 +1,475 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/db/kv" +) + +type pbinTestCountingCtx struct { + PatriciaContext + branchReads int +} + +func (c *pbinTestCountingCtx) Branch(prefix []byte) ([]byte, kv.Step, error) { + c.branchReads++ + return c.PatriciaContext.Branch(prefix) +} + +// pbinTestLeaf is one storage entry in the three forms a fold needs: the plain +// key state is read by, the tree key the path is cut from, and the encoded value +// the engine and the oracle hash. +type pbinTestLeaf struct { + plainKey []byte + treeKey []byte + storage []byte + value [pbinValueLength]byte +} + +func pbinTestStorageLeaf(treeKey []byte, seed byte) pbinTestLeaf { + storage := []byte{seed, seed ^ 0xFF} + return pbinTestLeaf{ + plainKey: bytes.Repeat([]byte{seed}, length.Addr+length.Hash), + treeKey: treeKey, + storage: storage, + value: pbinEncodeStorageValue(storage), + } +} + +func (l pbinTestLeaf) update() Update { + u := Update{Flags: StorageUpdate, StorageLen: int8(len(l.storage))} + copy(u.Storage[:], l.storage) + return u +} + +// cell cuts the leaf's tree key at depth, the way a row at that depth holds it. +func (l pbinTestLeaf) cell(t *testing.T, depth int16) pbinCell { + t.Helper() + full := pbinPathFromBytes(l.treeKey) + c := pbinTestEmptyCell() + c.kind = pbinNodeLeaf + c.prefix = full.slice(depth, full.bitLen) + copy(c.storageAddr[:], l.plainKey) + c.storageAddrLen = length.Addr + length.Hash + c.Update = l.update() + c.loaded = cellLoadStorage + return c +} + +func (l pbinTestLeaf) entry() pbinOracleEntry { + return pbinOracleEntry{key: l.treeKey, value: l.value[:]} +} + +func pbinTestPutState(t *testing.T, ms *MockState, leaves ...pbinTestLeaf) { + t.Helper() + keys := make([][]byte, 0, len(leaves)) + updates := make([]Update, 0, len(leaves)) + for _, l := range leaves { + keys = append(keys, l.plainKey) + updates = append(updates, l.update()) + } + require.NoError(t, ms.applyPlainUpdates(keys, updates)) +} + +// The zone byte is off limits: it selects the value encoding. +func pbinTestTreeKeyFlipped(t *testing.T, key []byte, d int16) []byte { + t.Helper() + require.GreaterOrEqual(t, d, int16(8), "bit %d is inside the zone byte", d) + require.Less(t, int(d), len(key)*8) + out := bytes.Clone(key) + out[d/8] ^= 1 << (7 - uint(d%8)) + return out +} + +func pbinTestBaseStorageKey() []byte { + return pbinTreeKeyStorage(pbinOracleAddr(7), pbinOracleSlot(1000)) +} + +func pbinTestKeyPrefix(key []byte, bitLen int16) pbinBitpath { + full := pbinPathFromBytes(key) + return full.slice(0, bitLen) +} + +func pbinTestSeedRow(pph *PBinPatriciaHashed, currentKey pbinBitpath, depth int16, cells [2]pbinCell, touchMap, afterMap uint16) { + pph.currentKey = currentKey + pph.grid.rows[0] = cells + pph.grid.depths[0] = depth + pph.grid.touchMap[0], pph.grid.afterMap[0] = touchMap, afterMap + pph.grid.activeRows = 1 +} + +// pbinTestFillCell fills a row cell the way updateCell does: touched and present. +func pbinTestFillCell(pph *PBinPatriciaHashed, row int, bit uint64, c pbinCell) { + pph.grid.rows[row][bit] = c + pph.grid.touchMap[row] |= uint16(1) << bit + pph.grid.afterMap[row] |= uint16(1) << bit +} + +func pbinTestBranchOrder(t *testing.T, a, b pbinTestLeaf, divergence int16) (left, right pbinTestLeaf) { + t.Helper() + path := pbinPathFromBytes(a.treeKey) + if path.bit(divergence) == 1 { + return b, a + } + return a, b +} + +// Divergence points span both word boundaries of the path. Nothing merges the +// record a branch fold writes with a predecessor, so it must also survive a +// decode and re-encode unchanged. +func TestPBinFoldBranchMatchesOracle(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + for _, divergence := range []int16{8, 63, 64, 65, 271, 527} { + t.Run(fmt.Sprintf("bit %d", divergence), func(t *testing.T) { + t.Parallel() + + a := pbinTestStorageLeaf(base, 0x11) + b := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, divergence), 0x22) + left, right := pbinTestBranchOrder(t, a, b, divergence) + + ms := NewMockState(t) + ctx := &pbinTestCountingCtx{PatriciaContext: ms} + pph := NewPBinPatriciaHashed(ctx) + + currentKey := pbinTestKeyPrefix(a.treeKey, divergence) + cells := [2]pbinCell{left.cell(t, divergence+1), right.cell(t, divergence+1)} + pbinTestSeedRow(pph, currentKey, divergence+1, cells, 0b11, 0b11) + + require.NoError(t, pph.fold()) + require.Equal(t, 0, pph.grid.activeRows) + require.Equal(t, int16(0), pph.currentKey.bitLen) + require.True(t, pph.rootTouched) + require.True(t, pph.rootPresent) + require.Zero(t, ctx.branchReads, "a fold of loaded cells reads nothing") + + require.Equal(t, pbinNodeBranch, pph.grid.root.kind) + require.Equal(t, currentKey, pph.grid.root.prefix) + want := pbinOracleRoot([]pbinOracleEntry{a.entry(), b.entry()}) + require.Equal(t, common.Hash(want), pph.grid.root.hash) + + data, _, err := ms.Branch(pbinEncodeBitPath(¤tKey)) + require.NoError(t, err) + require.NotEmpty(t, data, "a branch fold stores its row") + + var stored [2]pbinCell + touchMap, afterMap, err := pbinDecodeBranch(data, &stored) + require.NoError(t, err) + require.Equal(t, uint16(0b11), touchMap) + require.Equal(t, uint16(0b11), afterMap) + require.Equal(t, cells[0].prefix, stored[0].prefix) + require.Equal(t, cells[1].prefix, stored[1].prefix) + + var enc pbinBranchEncoder + again, err := enc.encode(touchMap, afterMap, &stored) + require.NoError(t, err) + require.Equal(t, data, []byte(again)) + }) + } +} + +// Folding a row as a branch with anything but two children is a lost or +// duplicated sibling, which at arity 2 is half the subtree. +func TestPBinFoldBranchRejectsWrongArity(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + a := pbinTestStorageLeaf(base, 0x11) + + pph, _ := pbinTestEngine(t) + cells := [2]pbinCell{a.cell(t, 9), pbinTestEmptyCell()} + pbinTestSeedRow(pph, pbinTestKeyPrefix(a.treeKey, 8), 9, cells, 0b01, 0b01) + + require.Error(t, pph.foldBranch(0, 0, 0, 9, &pph.grid.root)) +} + +func TestPBinFoldRejectsInconsistentGrid(t *testing.T) { + t.Parallel() + + t.Run("no active rows", func(t *testing.T) { + t.Parallel() + pph, _ := pbinTestEngine(t) + require.Error(t, pph.fold()) + }) + t.Run("cell bit outside the arity", func(t *testing.T) { + t.Parallel() + pph, _ := pbinTestEngine(t) + pbinTestSeedRow(pph, pbinBitpath{}, 1, [2]pbinCell{}, 0b100, 0b100) + require.ErrorIs(t, pph.fold(), errPBinCellMaps) + }) + t.Run("key shorter than the row depth", func(t *testing.T) { + t.Parallel() + pph, _ := pbinTestEngine(t) + pbinTestSeedRow(pph, pbinBitpath{}, 5, [2]pbinCell{}, 0, 0b11) + require.Error(t, pph.fold()) + }) +} + +// Unfold consumes a shared prefix into the descent key, so the branch fold below +// sees none of it. The propagate that follows has to hand the node back its full +// prefix, which is inside its hash. +func TestPBinFoldPropagateRestoresDescendedNode(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + for _, divergence := range []int16{8, 63, 64, 65, 271, 527} { + t.Run(fmt.Sprintf("prefix of %d bits", divergence), func(t *testing.T) { + t.Parallel() + + a := pbinTestStorageLeaf(base, 0x33) + b := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, divergence), 0x44) + left, right := pbinTestBranchOrder(t, a, b, divergence) + prefix := pbinTestKeyPrefix(a.treeKey, divergence) + + ms := NewMockState(t) + pbinTestPutState(t, ms, a, b) + + // Build the node once, then meet it again through a cell that knows only + // its prefix and hash, the way a reload would. + builder := NewPBinPatriciaHashed(ms) + cells := [2]pbinCell{left.cell(t, divergence+1), right.cell(t, divergence+1)} + pbinTestSeedRow(builder, prefix, divergence+1, cells, 0b11, 0b11) + require.NoError(t, builder.fold()) + nodeHash := builder.grid.root.hash + + ctx := &pbinTestCountingCtx{PatriciaContext: ms} + pph := NewPBinPatriciaHashed(ctx) + pph.grid.root = pbinTestEmptyCell() + pph.grid.root.kind = pbinNodeBranch + pph.grid.root.prefix = prefix + pph.grid.root.hash = nodeHash + pph.grid.root.hashLen = length.Hash + pph.rootPresent = true + + probe := pbinPathFromBytes(a.treeKey) + u := pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldDescend, matched: divergence}, u) + require.NoError(t, pph.unfold(&probe, u)) + require.Equal(t, int16(0), pph.grid.rows[0][probe.bit(divergence-1)].hashLen, + "re-cutting a prefix invalidates the hash it is inside") + + u = pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldRecord}, u) + require.NoError(t, pph.unfold(&probe, u)) + require.Equal(t, 2, pph.grid.activeRows) + + require.NoError(t, pph.fold()) + require.NoError(t, pph.fold()) + + require.Equal(t, 0, pph.grid.activeRows) + require.Equal(t, pbinNodeBranch, pph.grid.root.kind) + require.Equal(t, prefix, pph.grid.root.prefix, "the propagate hands back every consumed bit") + require.Equal(t, nodeHash, pph.grid.root.hash) + + want := pbinOracleRoot([]pbinOracleEntry{a.entry(), b.entry()}) + require.Equal(t, common.Hash(want), pph.grid.root.hash) + require.Equal(t, 1, ctx.branchReads, "the descent reads the node once") + require.Zero(t, pph.counters.materializeReads, "a descended node keeps its children") + }) + } +} + +// A leaf commits its complete key, so shortening the prefix it sits behind +// invalidates nothing and no record has to be read to rebuild it. +func TestPBinFoldSplitLeafSurvivorReadsNoBranch(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + for _, divergence := range []int16{8, 63, 64, 65, 271, 527} { + t.Run(fmt.Sprintf("bit %d", divergence), func(t *testing.T) { + t.Parallel() + + a := pbinTestStorageLeaf(base, 0x55) + c := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, divergence), 0x66) + + ms := NewMockState(t) + pbinTestPutState(t, ms, a, c) + ctx := &pbinTestCountingCtx{PatriciaContext: ms} + pph := NewPBinPatriciaHashed(ctx) + pph.grid.root = a.cell(t, 0) + pph.rootPresent = true + + probe := pbinPathFromBytes(c.treeKey) + u := pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldSplit, matched: divergence}, u) + require.NoError(t, pph.unfold(&probe, u)) + require.Equal(t, uint64(1), pph.counters.splitsInsidePrefix) + + pbinTestFillCell(pph, 0, probe.bit(divergence), c.cell(t, divergence+1)) + require.NoError(t, pph.fold()) + + want := pbinOracleRoot([]pbinOracleEntry{a.entry(), c.entry()}) + require.Equal(t, common.Hash(want), pph.grid.root.hash) + require.Zero(t, ctx.branchReads, "a leaf survivor needs no record") + require.Zero(t, pph.counters.materializeReads) + }) + } +} + +// The survivor of a split keeps prefix[matched+1:], and the prefix is inside its +// hash, so the cached hash is stale. The engine has to rebuild it from the +// survivor's own children before the fold above can use it. +func TestPBinFoldSplitInsidePrefixMatchesOracle(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + const nodePrefixBits = 527 + + for _, divergence := range []int16{8, 63, 64, 65, 271, 526} { + t.Run(fmt.Sprintf("bit %d of %d", divergence, nodePrefixBits), func(t *testing.T) { + t.Parallel() + + a := pbinTestStorageLeaf(base, 0x77) + b := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, nodePrefixBits), 0x88) + c := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, divergence), 0x99) + left, right := pbinTestBranchOrder(t, a, b, nodePrefixBits) + nodePrefix := pbinTestKeyPrefix(a.treeKey, nodePrefixBits) + + ms := NewMockState(t) + pbinTestPutState(t, ms, a, b, c) + + builder := NewPBinPatriciaHashed(ms) + cells := [2]pbinCell{left.cell(t, nodePrefixBits+1), right.cell(t, nodePrefixBits+1)} + pbinTestSeedRow(builder, nodePrefix, nodePrefixBits+1, cells, 0b11, 0b11) + require.NoError(t, builder.fold()) + + ctx := &pbinTestCountingCtx{PatriciaContext: ms} + pph := NewPBinPatriciaHashed(ctx) + pph.grid.root = pbinTestEmptyCell() + pph.grid.root.kind = pbinNodeBranch + pph.grid.root.prefix = nodePrefix + pph.grid.root.hash = builder.grid.root.hash + pph.grid.root.hashLen = length.Hash + pph.rootPresent = true + + probe := pbinPathFromBytes(c.treeKey) + u := pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldSplit, matched: divergence}, u) + require.NoError(t, pph.unfold(&probe, u)) + require.Equal(t, uint64(1), pph.counters.splitsInsidePrefix) + + survivorBit := 1 - probe.bit(divergence) + survivor := &pph.grid.rows[0][survivorBit] + require.Equal(t, pbinNodeBranch, survivor.kind) + require.Equal(t, nodePrefix.slice(divergence+1, nodePrefixBits), survivor.prefix) + require.Equal(t, int16(0), survivor.hashLen, "a shortened prefix voids the cached hash") + + pbinTestFillCell(pph, 0, probe.bit(divergence), c.cell(t, divergence+1)) + require.NoError(t, pph.fold()) + + want := pbinOracleRoot([]pbinOracleEntry{a.entry(), b.entry(), c.entry()}) + require.Equal(t, common.Hash(want), pph.grid.root.hash) + require.Equal(t, nodePrefix.slice(0, divergence), pph.grid.root.prefix) + require.Equal(t, uint64(1), pph.counters.materializeReads, "the survivor is rebuilt from one record") + }) + } +} + +// A cell whose subtree is stored but missing cannot be rebuilt, and passing the +// stale hash off as current would commit a wrong root. +func TestPBinFoldSplitInsidePrefixMissingRecord(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + const nodePrefixBits = 271 + const divergence = 64 + + a := pbinTestStorageLeaf(base, 0xA1) + c := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, divergence), 0xA2) + nodePrefix := pbinTestKeyPrefix(a.treeKey, nodePrefixBits) + + ms := NewMockState(t) + pbinTestPutState(t, ms, a, c) + pph := NewPBinPatriciaHashed(ms) + pph.grid.root = pbinTestEmptyCell() + pph.grid.root.kind = pbinNodeBranch + pph.grid.root.prefix = nodePrefix + pph.grid.root.hash = common.Hash{0xDE, 0xAD} + pph.grid.root.hashLen = length.Hash + pph.rootPresent = true + + probe := pbinPathFromBytes(c.treeKey) + require.NoError(t, pph.unfold(&probe, pph.needUnfolding(&probe))) + pbinTestFillCell(pph, 0, probe.bit(divergence), c.cell(t, divergence+1)) + + require.ErrorIs(t, pph.fold(), errPBinMissingBranch) +} + +// A record carries plain keys, not values, so a sibling that nothing in this run +// touched has to be read back from state before it can be hashed. +func TestPBinFoldLoadsSiblingState(t *testing.T) { + t.Parallel() + + base := pbinTestBaseStorageKey() + const divergence = 271 + + a := pbinTestStorageLeaf(base, 0xB1) + b := pbinTestStorageLeaf(pbinTestTreeKeyFlipped(t, base, divergence), 0xB2) + left, right := pbinTestBranchOrder(t, a, b, divergence) + prefix := pbinTestKeyPrefix(a.treeKey, divergence) + + ms := NewMockState(t) + pbinTestPutState(t, ms, a, b) + pph := NewPBinPatriciaHashed(ms) + + stateless := [2]pbinCell{left.cell(t, divergence+1), right.cell(t, divergence+1)} + for i := range stateless { + stateless[i].Update.Reset() + stateless[i].loaded = cellLoadNone + } + pbinTestSeedRow(pph, prefix, divergence+1, stateless, 0b11, 0b11) + require.NoError(t, pph.fold()) + + want := pbinOracleRoot([]pbinOracleEntry{a.entry(), b.entry()}) + require.Equal(t, common.Hash(want), pph.grid.root.hash) +} + +// A row that keeps nothing takes its stored record with it and reports the +// absence upwards. +func TestPBinFoldDeleteDropsRecord(t *testing.T) { + t.Parallel() + + pph, ms := pbinTestEngine(t) + key := pbinBitpath{} + pbinTestPutTopRecord(t, ms, [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "010"), + pbinTestSpecCell(t, pbinNodeLeaf, "110"), + }) + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, "0101")) + pbinTestUnfoldStep(t, pph, &probe) + require.True(t, pph.grid.branchBefore[0]) + pph.grid.touchMap[0], pph.grid.afterMap[0] = 0b11, 0 + + require.NoError(t, pph.fold()) + require.Equal(t, pbinTestEmptyCell(), pph.grid.root) + require.True(t, pph.rootTouched) + require.False(t, pph.rootPresent) + + data, _, err := ms.Branch(pbinEncodeBitPath(&key)) + require.NoError(t, err) + require.Empty(t, data) +} diff --git a/execution/commitment/pbin_fuzz_test.go b/execution/commitment/pbin_fuzz_test.go new file mode 100644 index 00000000000..11e4f871ed3 --- /dev/null +++ b/execution/commitment/pbin_fuzz_test.go @@ -0,0 +1,194 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// pbinFuzzSlots is the slot pool the generator draws from. Slots picked at +// random essentially never share a stem, so a fuzzer free to choose all 32 bytes +// would only build shallow trees, never reaching sub-index sharing, group +// boundaries or the account/storage zone split. +var pbinFuzzSlots = []uint64{0, 1, 2, 63, 64, 65, 66, 127, 128, 255, 256, 257, 258, 511, 512, 1000, 1 << 20, 1<<20 + 1} + +// pbinFuzzAccountBit asks for an account write, pbinFuzzDeleteBit for an +// account removal; the low three bits of the selector pick the address. +const ( + pbinFuzzAccountBit = 0x08 + pbinFuzzDeleteBit = 0x10 +) + +// pbinFuzzCodeShapes is the code pool: a delegation indicator, codes ending in +// an all-zero chunk, and chunk counts straddling the 255/256 and 511/512 group +// boundaries. Address seeds fold onto shapes modulo four, so seeds four apart +// always share bytecode. +var pbinFuzzCodeShapes = [][]byte{ + nil, + pbinTestCode(23), + pbinTestIndicator(0x37), + pbinTestCode(2 * pbinChunkDataLen), + append(pbinTestCode(2*pbinChunkDataLen), make([]byte, pbinChunkDataLen)...), + pbinTestCode(255 * pbinChunkDataLen), + pbinTestCode(256 * pbinChunkDataLen), + append(pbinTestCode(256*pbinChunkDataLen), make([]byte, pbinChunkDataLen)...), + pbinTestCode(257 * pbinChunkDataLen), + pbinTestCode(511 * pbinChunkDataLen), + pbinTestCode(512 * pbinChunkDataLen), + pbinTestCode(513 * pbinChunkDataLen), +} + +// pbinFuzzCode keys the code on the address so it stays fixed for a whole run, +// which is what keeps the oracle valid: a redeploy to shorter code leaves its +// high chunks in the tree, and the oracle only knows the final state. +func pbinFuzzCode(addrSeed, salt byte) []byte { + return pbinFuzzCodeShapes[(int(addrSeed%4)+int(salt))%len(pbinFuzzCodeShapes)] +} + +// pbinFuzzCorpus reads the input three bytes at a time: what to write, where, +// and with what value. A zero value byte writes zero storage, which is the +// deletion encoding. +func pbinFuzzCorpus(data []byte, codeSalt byte) *pbinTestCorpus { + c := new(pbinTestCorpus) + for i := 0; i+2 < len(data); i += 3 { + where, slot, value := data[i], data[i+1], data[i+2] + addrSeed := where & 0x07 + addr := pbinOracleAddr(uint64(addrSeed)) + switch { + case where&pbinFuzzDeleteBit != 0: + c.remove(addr) + case where&pbinFuzzAccountBit != 0: + if code := pbinFuzzCode(addrSeed, codeSalt); code != nil { + c.accountWithCodeBytes(addr, uint64(value), uint64(value)*1_000_000_007, code) + } else { + c.account(addr, uint64(value), uint64(value)*1_000_000_007, common.Hash{value, 0xC0}) + } + case value == 0: + c.storage(addr, pbinOracleSlot(pbinFuzzSlots[int(slot)%len(pbinFuzzSlots)])) + default: + c.storage(addr, pbinOracleSlot(pbinFuzzSlots[int(slot)%len(pbinFuzzSlots)]), value, value^0xFF) + } + } + return c +} + +// pbinFuzzBatches cuts the corpus in two, so a run also covers what one Process +// call leaves for the next to read back — and on which side of the cut a +// removal lands, which decides whether an account created and destroyed by the +// corpus ever materializes. +func pbinFuzzBatches(data []byte, cut, codeSalt byte) []*pbinTestCorpus { + c := pbinFuzzCorpus(data, codeSalt) + if len(c.plainKeys) == 0 { + return nil + } + at := int(cut) % (len(c.plainKeys) + 1) + batches := make([]*pbinTestCorpus, 0, 2) + for _, b := range []*pbinTestCorpus{ + {plainKeys: c.plainKeys[:at], updates: c.updates[:at], codes: c.codes}, + {plainKeys: c.plainKeys[at:], updates: c.updates[at:], codes: c.codes}, + } { + if len(b.plainKeys) > 0 { + batches = append(batches, b) + } + } + return batches +} + +// TestPBinFuzzCorpusCoversNewShapes pins the generator's reach, so the fuzz +// seeds cannot go vacuous: delegation, shared bytecode, all-zero chunks, both +// group-boundary straddles, and account removal. +func TestPBinFuzzCorpusCoversNewShapes(t *testing.T) { + t.Parallel() + + require.True(t, pbinIsDelegation(pbinFuzzCode(0, 2))) + require.Equal(t, pbinFuzzCode(1, 2), pbinFuzzCode(5, 2), "address seeds four apart share a shape") + require.NotEmpty(t, pbinFuzzCode(1, 2)) + + counts := make(map[int]bool, len(pbinFuzzCodeShapes)) + zeroTails := 0 + for _, shape := range pbinFuzzCodeShapes { + chunks := pbinChunkifyCode(shape) + counts[len(chunks)] = true + if len(chunks) > 0 && chunks[len(chunks)-1] == ([pbinValueLength]byte{}) { + zeroTails++ + } + } + for _, straddle := range []int{255, 256, 257, 511, 512, 513} { + require.True(t, counts[straddle], "no shape holds %d chunks", straddle) + } + require.NotZero(t, zeroTails, "no shape ends in an all-zero chunk") + + removal := pbinFuzzCorpus([]byte{pbinFuzzDeleteBit, 0, 0}, 0) + require.Len(t, removal.updates, 1) + require.True(t, removal.updates[0].Deleted()) +} + +// FuzzPBinProcessMatchesOracle: whatever the generator produces, the engine's +// root must equal the reference tree's over the same leaves, and the records it +// left behind must rebuild that root on their own. +// +// go test ./execution/commitment/ -run=Fuzz -fuzz=FuzzPBinProcessMatchesOracle -fuzztime=60s +func FuzzPBinProcessMatchesOracle(f *testing.F) { + // Seeds are (selector, slot, value) triples: bit 3 of the selector asks for an + // account, bit 4 for its removal, the low bits pick the address, and the slot + // byte indexes the pool. + f.Add([]byte{0x08, 0, 1, 0x09, 0, 2}, byte(0), byte(0)) // two accounts, no code + f.Add([]byte{0x00, 10, 1, 0x00, 11, 2, 0x00, 12, 3}, byte(2), byte(0)) // three slots of one group + f.Add([]byte{0x00, 3, 1, 0x00, 4, 2, 0x08, 0, 3}, byte(1), byte(0)) // the 63/64 zone boundary plus a header + f.Add([]byte{0x08, 0, 1, 0x00, 15, 2, 0x01, 15, 3, 0x02, 16, 4}, byte(3), byte(0)) // one slot per address + f.Add([]byte{0x00, 10, 1, 0x00, 10, 2, 0x00, 10, 3}, byte(1), byte(0)) // the same slot rewritten + f.Add([]byte{0x08, 0, 1, 0x00, 5, 2, 0x08, 0, 3}, byte(1), byte(1)) // code interleaved with a header slot + f.Add([]byte{0x08, 0, 1, 0x09, 0, 2, 0x00, 17, 3}, byte(2), byte(4)) // a zero-tailed code beside a 255-chunk one + f.Add([]byte{0x08, 0, 1, 0x18, 0, 0}, byte(1), byte(2)) // a delegation inserted, then its account removed + f.Add([]byte{0x08, 0, 1, 0x08, 0, 2, 0x0C, 0, 3}, byte(1), byte(2)) // a delegation rewritten, plus a second authority on the target + f.Add([]byte{0x08, 0, 1}, byte(0), byte(7)) // a zero chunk alone in its group + f.Add([]byte{0x09, 0, 1, 0x0D, 0, 2, 0x15, 0, 0}, byte(2), byte(2)) // shared code outliving one holder + f.Add([]byte{0x09, 0, 1, 0x0D, 0, 2, 0x15, 0, 0}, byte(0), byte(2)) // shared code whose second holder dies in the writing batch + f.Add([]byte{0x08, 0, 1, 0x09, 0, 2, 0x0A, 0, 3, 0x0B, 0, 4}, byte(0), byte(5)) // chunk counts straddling 255/256 + f.Add([]byte{0x08, 0, 1, 0x09, 0, 2, 0x0A, 0, 3}, byte(0), byte(9)) // chunk counts straddling 511/512 + f.Add([]byte{0x00, 10, 1, 0x00, 10, 0}, byte(1), byte(0)) // a live slot zeroed by the next batch + f.Add([]byte{0x08, 0, 5, 0x08, 0, 0}, byte(1), byte(0)) // basic data zeroed while the code-hash leaf stays + + f.Fuzz(func(t *testing.T, data []byte, cut, codeSalt byte) { + batches := pbinFuzzBatches(data, cut, codeSalt) + if len(batches) == 0 { + return + } + + pph, ms := pbinTestEngine(t) + var root []byte + for _, b := range batches { + b.applyTo(t, ms) + root = pbinTestProcess(t, pph, b.plainKeys, b.updates) + } + require.Len(t, root, length.Hash) + + final := pbinTestFinalEntries(t, batches...) + want := pbinOracleRoot(final) + require.Equal(t, want[:], root) + + // A tree of one leaf is that leaf and writes no record. + if len(final) > 1 { + pbinTestVerifyRecords(t, ms, root, len(final)) + } + }) +} diff --git a/execution/commitment/pbin_hash.go b/execution/commitment/pbin_hash.go new file mode 100644 index 00000000000..51e13314de6 --- /dev/null +++ b/execution/commitment/pbin_hash.go @@ -0,0 +1,191 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "errors" + "fmt" + + keccak "github.com/erigontech/fastkeccak" + "lukechampine.com/blake3" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// Node tags separating the two preimage shapes EIP-8297 defines (eip:"Node merkelization"). +const ( + pbinLeafTag = 0x00 + pbinBranchTag = 0x01 + + // pbinHashBufLen is the longest preimage either shape produces: tag, bit count, + // packed prefix, both child hashes. + pbinHashBufLen = 1 + 2 + (pbinMaxPathBits+7)/8 + 2*length.Hash +) + +// pbinEmptyTreeHash is the hash of an absent subtree: 32 zero bytes +// (eip:"Node merkelization"). +// Not empty.RootHash — the RLP empty-string MPT root would build a different tree. +var pbinEmptyTreeHash common.Hash + +var errPBinCellHash = errors.New("pbin: cell cannot be hashed") + +// pbinHashFn is H, which EIP-8297 leaves open +// (eip:"SNARK friendliness and post-quantum security"). Tree-key derivation +// hashes with H too, so a suite is only fully swapped when pbinDigestCache is +// swapped with it. +type pbinHashFn func([]byte) common.Hash + +// Names for H, as the --experimental.bin-commitment.hash flag spells them. +const ( + PBinHashKeccak = "keccak" + PBinHashBlake3 = "blake3" +) + +// pbinSelectedSum is H for every binary-trie engine this process builds; nil is +// Keccak-256. +var pbinSelectedSum pbinHashFn + +// SetPBinHashSuite selects H by name. Call it before the first engine is built: +// roots already computed under the previous suite do not match. +func SetPBinHashSuite(name string) error { + switch name { + case "", PBinHashKeccak: + pbinSelectedSum = nil + case PBinHashBlake3: + pbinSelectedSum = func(b []byte) common.Hash { return common.Hash(blake3.Sum256(b)) } + default: + return fmt.Errorf("unknown bin commitment hash %q, want %q or %q", name, PBinHashKeccak, PBinHashBlake3) + } + return nil +} + +func PBinHashSuiteName() string { + if pbinSelectedSum == nil { + return PBinHashKeccak + } + return PBinHashBlake3 +} + +// pbinHasher applies H to node preimages. Its zero value is ready and hashes with +// Keccak-256. +type pbinHasher struct { + buf [pbinHashBufLen]byte + sum pbinHashFn + tracer witnessTracer // nil on the normal commitment path; see pbin_witness.go +} + +func (h *pbinHasher) hash(preimage []byte) common.Hash { + if h.sum != nil { + return h.sum(preimage) + } + return keccak.Sum256(preimage) +} + +// pbinAppendBitPrefix is the spec's encode_bit_prefix (eip:"Node merkelization"). The leading +// bit count is what keeps a 7-bit prefix distinct from an 8-bit one that agrees +// with it on the pad bit. +func pbinAppendBitPrefix(dst []byte, p *pbinBitpath) []byte { + return p.appendPackedBits(binary.BigEndian.AppendUint16(dst, uint16(p.bitLen))) +} + +// branchHash is H(0x01 || encode_bit_prefix(prefix) || left || right); an absent +// child passes pbinEmptyTreeHash rather than being omitted. +func (h *pbinHasher) branchHash(prefix *pbinBitpath, left, right *common.Hash) common.Hash { + buf := pbinAppendBitPrefix(append(h.buf[:0], pbinBranchTag), prefix) + buf = append(buf, left[:]...) + buf = append(buf, right[:]...) + hash := h.hash(buf) + h.emitNode(buf, &hash) + return hash +} + +// cellHash hashes the cell reached by path; a leaf's complete key is path +// followed by the cell's own prefix. +func (h *pbinHasher) cellHash(c *pbinCell, path *pbinBitpath) (common.Hash, error) { + switch c.kind { + case pbinNodeEmpty: + return pbinEmptyTreeHash, nil + case pbinNodeBranch: + if c.childrenSet { + return h.branchHash(&c.prefix, &c.children[0], &c.children[1]), nil + } + if c.hashLen != length.Hash { + return common.Hash{}, fmt.Errorf("%w: branch cell holds %d hash bytes", errPBinCellHash, c.hashLen) + } + return c.hash, nil + case pbinNodeLeaf: + return h.leafCellHash(c, path) + default: + return common.Hash{}, fmt.Errorf("%w: unknown node kind %d", errPBinCellHash, c.kind) + } +} + +func (h *pbinHasher) leafCellHash(c *pbinCell, path *pbinBitpath) (common.Hash, error) { + full := *path + if int(full.bitLen)+int(c.prefix.bitLen) > pbinMaxPathBits { + return common.Hash{}, fmt.Errorf("%w: leaf key of %d+%d bits overflows", errPBinCellHash, full.bitLen, c.prefix.bitLen) + } + full.append(&c.prefix) + if full.bitLen%8 != 0 { + return common.Hash{}, fmt.Errorf("%w: leaf key of %d bits is not whole bytes", errPBinCellHash, full.bitLen) + } + + buf := full.appendPackedBits(append(h.buf[:0], pbinLeafTag)) + key := buf[1:] + // Key length is fixed per zone, which is what keeps the key space prefix-free + // (eip:"Tree embedding"). + if want, known := pbinZoneKeyLength(key[0]); !known || len(key) != want { + return common.Hash{}, fmt.Errorf("%w: leaf key %x is no key of zone %#x", errPBinCellHash, key, key[0]) + } + value, err := pbinLeafValue(key, &c.Update) + if err != nil { + return common.Hash{}, err + } + buf = append(buf, value[:]...) + hash := h.hash(buf) + h.emitNode(buf, &hash) + return hash, nil +} + +func pbinLeafValue(key []byte, u *Update) ([pbinValueLength]byte, error) { + switch key[0] { + case pbinStorageZone: + return pbinEncodeStorageValue(u.Storage[:u.StorageLen]), nil + case pbinCodeZone: + return pbinRecordLeafValue(u) + case pbinAccountZone: + default: + return [pbinValueLength]byte{}, fmt.Errorf("%w: zone %#x names no leaf", errPBinCellHash, key[0]) + } + switch subIndex := key[len(key)-1]; { + case subIndex == pbinBasicDataLeafKey: + return pbinEncodeBasicData(u.Nonce, &u.Balance, u.CodeSize) + case subIndex == pbinCodeHashLeafKey: + return pbinCodeHashValue(u.CodeHash), nil + case subIndex == pbinDelegationLeafKey: + // An EIP-7702 indicator is no account field, so the leaf carries its own bytes. + return pbinRecordLeafValue(u) + case subIndex >= pbinHeaderStorageOffset && subIndex < pbinHeaderStorageOffset+pbinHeaderStorageSlots: + return pbinEncodeStorageValue(u.Storage[:u.StorageLen]), nil + default: + // Sub-indices the embedding reserves (eip:"Header values"): not packed from state, + // so the value must already be 32 whole bytes. + return pbinRecordLeafValue(u) + } +} diff --git a/execution/commitment/pbin_hash_test.go b/execution/commitment/pbin_hash_test.go new file mode 100644 index 00000000000..19a9b8256a2 --- /dev/null +++ b/execution/commitment/pbin_hash_test.go @@ -0,0 +1,346 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "fmt" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + keccak "github.com/erigontech/fastkeccak" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/empty" +) + +// leafHash is H(0x00 || key || value) over the complete tree key. The engine +// builds the same preimage from a cell; taking the key and value directly is +// what lets a test state the expected hash. +func (h *pbinHasher) leafHash(key, value []byte) common.Hash { + if len(key) != pbinAccountKeyLength && len(key) != pbinStorageKeyLength { + panic(fmt.Sprintf("pbin: leaf key of %d bytes is neither zone length", len(key))) + } + if len(value) != pbinValueLength { + panic(fmt.Sprintf("pbin: leaf value of %d bytes, want %d", len(value), pbinValueLength)) + } + buf := append(h.buf[:0], pbinLeafTag) + buf = append(buf, key...) + buf = append(buf, value...) + return keccak.Sum256(buf) +} + +func pbinTestPathFromBits(t *testing.T, bits []byte) pbinBitpath { + t.Helper() + require.LessOrEqual(t, len(bits), pbinMaxPathBits) + var p pbinBitpath + for i, b := range bits { + p.setBitAt(int16(i), uint64(b)) + } + p.bitLen = int16(len(bits)) + return p +} + +// pbinTestBitSpec reads a "1011" literal into the oracle's one-bit-per-byte form. +func pbinTestBitSpec(t *testing.T, spec string) []byte { + t.Helper() + bits := make([]byte, 0, len(spec)) + for _, r := range spec { + switch r { + case '0': + bits = append(bits, 0) + case '1': + bits = append(bits, 1) + default: + t.Fatalf("bit spec %q holds %q", spec, r) + } + } + return bits +} + +func pbinTestBitPattern(n int) []byte { + bits := make([]byte, n) + for i := range bits { + bits[i] = byte((i*7 + i/3) & 1) + } + return bits +} + +func pbinTestOracleLeaf(addr, slot uint64) *pbinOracleLeaf { + return &pbinOracleLeaf{ + key: pbinTreeKeyStorage(pbinOracleAddr(addr), pbinOracleSlot(slot)), + value: pbinOracleValue(addr*1000 + slot), + } +} + +// EIP-8297's empty subtree is 32 zero bytes (eip:"Node merkelization"), not the empty-MPT root +// the rest of erigon reaches for. +func TestPBinEmptyTreeHash(t *testing.T) { + t.Parallel() + + require.Equal(t, make([]byte, 32), pbinEmptyTreeHash[:]) + require.NotEqual(t, empty.RootHash, pbinEmptyTreeHash) + + var h pbinHasher + var c pbinCell + var path pbinBitpath + got, err := h.cellHash(&c, &path) + require.NoError(t, err) + require.Equal(t, pbinEmptyTreeHash, got) + require.NotEqual(t, empty.RootHash, got) +} + +// The lengths below are the ones where bit-prefix padding can go wrong. +func TestPBinAppendBitPrefixMatchesOracle(t *testing.T) { + t.Parallel() + + for _, n := range []int{0, 1, 7, 8, 9, 15, 16, 17, 63, 64, 65, 255, 256, 271, 272, 527, pbinMaxPathBits} { + bits := pbinTestBitPattern(n) + path := pbinTestPathFromBits(t, bits) + require.Equal(t, pbinOracleEncodeBitPrefix(bits), pbinAppendBitPrefix(nil, &path), "%d bits", n) + } +} + +func TestPBinLeafHashMatchesOracle(t *testing.T) { + t.Parallel() + + var h pbinHasher + for _, tc := range []struct { + name string + leaf *pbinOracleLeaf + }{ + { + name: "account key", + leaf: &pbinOracleLeaf{ + key: pbinTreeKeyAccount(pbinOracleAddr(1), pbinBasicDataLeafKey), + value: pbinOracleValue(1), + }, + }, + { + name: "storage key", + leaf: pbinTestOracleLeaf(2, 1000), + }, + } { + t.Run(tc.name, func(t *testing.T) { + want := pbinOracleMerkelize(tc.leaf) + require.Equal(t, common.Hash(want), h.leafHash(tc.leaf.key, tc.leaf.value)) + }) + } +} + +func TestPBinBranchHashMatchesOracle(t *testing.T) { + t.Parallel() + + var h pbinHasher + left, right := pbinTestOracleLeaf(1, 0), pbinTestOracleLeaf(2, 0) + leftHash := h.leafHash(left.key, left.value) + rightHash := h.leafHash(right.key, right.value) + + for _, tc := range []struct { + name string + bits []byte + }{ + {name: "empty prefix", bits: nil}, + {name: "one bit", bits: pbinTestBitSpec(t, "1")}, + {name: "seven bits", bits: pbinTestBitSpec(t, "1011010")}, + {name: "eight bits", bits: pbinTestBitSpec(t, "10110101")}, + {name: "nine bits", bits: pbinTestBitSpec(t, "101101011")}, + {name: "one word", bits: pbinTestBitPattern(64)}, + {name: "past one word", bits: pbinTestBitPattern(65)}, + {name: "deepest branch a 528-bit key admits", bits: pbinTestBitPattern(pbinMaxPathBits - 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + want := pbinOracleMerkelize(&pbinOracleBranch{prefix: tc.bits, left: left, right: right}) + path := pbinTestPathFromBits(t, tc.bits) + require.Equal(t, common.Hash(want), h.branchHash(&path, &leftHash, &rightHash)) + }) + } +} + +// Covers a branch hash feeding another branch, not just a branch over leaves. +func TestPBinNestedBranchHashMatchesOracle(t *testing.T) { + t.Parallel() + + var h pbinHasher + a, b, c := pbinTestOracleLeaf(1, 0), pbinTestOracleLeaf(2, 0), pbinTestOracleLeaf(3, 0) + innerBits := pbinTestBitSpec(t, "10110") + outerBits := pbinTestBitSpec(t, "011") + + inner := &pbinOracleBranch{prefix: innerBits, left: a, right: b} + outer := &pbinOracleBranch{prefix: outerBits, left: inner, right: c} + want := pbinOracleMerkelize(outer) + + aHash := h.leafHash(a.key, a.value) + bHash := h.leafHash(b.key, b.value) + cHash := h.leafHash(c.key, c.value) + innerPath := pbinTestPathFromBits(t, innerBits) + innerHash := h.branchHash(&innerPath, &aHash, &bHash) + outerPath := pbinTestPathFromBits(t, outerBits) + + require.Equal(t, common.Hash(want), h.branchHash(&outerPath, &innerHash, &cHash)) +} + +// An absent child contributes the empty-subtree constant rather than being +// skipped. +func TestPBinBranchHashEmptyChild(t *testing.T) { + t.Parallel() + + var h pbinHasher + leaf := pbinTestOracleLeaf(4, 7) + leafHash := h.leafHash(leaf.key, leaf.value) + bits := pbinTestBitSpec(t, "0101") + + want := pbinOracleMerkelize(&pbinOracleBranch{prefix: bits, left: leaf, right: nil}) + path := pbinTestPathFromBits(t, bits) + require.Equal(t, common.Hash(want), h.branchHash(&path, &leafHash, &pbinEmptyTreeHash)) +} + +func TestPBinCellHashBranch(t *testing.T) { + t.Parallel() + + var h pbinHasher + var path pbinBitpath + + t.Run("returns the stored hash", func(t *testing.T) { + c := pbinCell{kind: pbinNodeBranch, hash: common.Hash{0xAB}, hashLen: 32} + got, err := h.cellHash(&c, &path) + require.NoError(t, err) + require.Equal(t, c.hash, got) + }) + t.Run("rejects a branch cell with no hash", func(t *testing.T) { + c := pbinCell{kind: pbinNodeBranch} + _, err := h.cellHash(&c, &path) + require.ErrorIs(t, err, errPBinCellHash) + }) +} + +func TestPBinCellHashLeaf(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(9) + storageKey := pbinTreeKeyStorage(addr, pbinOracleSlot(1000)) + headerSlotKey := pbinTreeKeyStorage(addr, pbinOracleSlot(5)) + codeHash := common.Hash{0xC0, 0xDE} + + balance := new(uint256.Int).SetUint64(0xDEADBEEF) + + basicData, err := pbinEncodeBasicData(7, balance, 0) + require.NoError(t, err) + + for _, tc := range []struct { + name string + key []byte + cell pbinCell + value [pbinValueLength]byte + }{ + { + name: "BASIC_DATA", + key: pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), + cell: pbinCell{Update: Update{Nonce: 7, Balance: *balance}}, + value: basicData, + }, + { + name: "CODE_HASH", + key: pbinTreeKeyAccount(addr, pbinCodeHashLeafKey), + cell: pbinCell{Update: Update{CodeHash: codeHash}}, + value: pbinCodeHashValue(codeHash), + }, + { + name: "header-zone storage slot", + key: headerSlotKey, + cell: pbinCell{Update: Update{Storage: common.Hash{0x11, 0x22}, StorageLen: 2}}, + value: pbinEncodeStorageValue([]byte{0x11, 0x22}), + }, + { + name: "storage-zone slot", + key: storageKey, + cell: pbinCell{Update: Update{Storage: common.Hash{0x33}, StorageLen: 1}}, + value: pbinEncodeStorageValue([]byte{0x33}), + }, + } { + t.Run(tc.name, func(t *testing.T) { + var h pbinHasher + full := pbinPathFromBytes(tc.key) + // Split the key so that both the descent path and the cell prefix carry + // real bits: the complete key is their concatenation, not either alone. + const split = 100 + path := full.slice(0, split) + cell := tc.cell + cell.kind = pbinNodeLeaf + cell.prefix = full.slice(split, full.bitLen) + + got, err := h.cellHash(&cell, &path) + require.NoError(t, err) + + want := pbinOracleMerkelize(&pbinOracleLeaf{key: tc.key, value: tc.value[:]}) + require.Equal(t, common.Hash(want), got) + }) + } +} + +func TestPBinCellHashRejectsMalformedLeaf(t *testing.T) { + t.Parallel() + + var h pbinHasher + key := pbinTreeKeyAccount(pbinOracleAddr(3), pbinBasicDataLeafKey) + full := pbinPathFromBytes(key) + + t.Run("key of neither zone length", func(t *testing.T) { + path := full.slice(0, 100) + c := pbinCell{kind: pbinNodeLeaf, prefix: full.slice(100, full.bitLen-1)} + _, err := h.cellHash(&c, &path) + require.ErrorIs(t, err, errPBinCellHash) + }) + t.Run("account-zone sub-index naming no leaf", func(t *testing.T) { + bad := pbinPathFromBytes(pbinTreeKey(pbinAccountZone, make([]byte, 32), pbinHeaderStorageOffset+pbinHeaderStorageSlots)) + path := bad.slice(0, 100) + c := pbinCell{kind: pbinNodeLeaf, prefix: bad.slice(100, bad.bitLen)} + _, err := h.cellHash(&c, &path) + require.ErrorIs(t, err, errPBinCellHash) + }) +} + +// Folds each two-key corpus by hand through the cell hasher, checking the +// primitives compose into the root the reference tree produces. +func TestPBinCellHashBuildsCorpusRoots(t *testing.T) { + t.Parallel() + + for _, corpus := range []pbinOracleCorpus{ + pbinOracleCorpusSplitAtBit0(), + pbinOracleCorpusSplitAtLastBit(), + } { + t.Run(corpus.name, func(t *testing.T) { + require.Len(t, corpus.entries, 2) + var h pbinHasher + a, b := corpus.entries[0], corpus.entries[1] + + aPath, bPath := pbinPathFromBytes(a.key), pbinPathFromBytes(b.key) + shared := pbinCommonPrefixBitsAt(&aPath, 0, &bPath) + prefix := aPath.slice(0, shared) + + left, right := a, b + if aPath.bit(shared) == 1 { + left, right = b, a + } + leftHash := h.leafHash(left.key, left.value) + rightHash := h.leafHash(right.key, right.value) + + require.Equal(t, common.Hash(pbinOracleRoot(corpus.entries)), h.branchHash(&prefix, &leftHash, &rightHash)) + }) + } +} diff --git a/execution/commitment/pbin_hashsuite_test.go b/execution/commitment/pbin_hashsuite_test.go new file mode 100644 index 00000000000..153cf66bef0 --- /dev/null +++ b/execution/commitment/pbin_hashsuite_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests move the process-wide hash selection, so none of them is parallel. + +func pbinRestoreHashSuite(t *testing.T) { + t.Helper() + prev := PBinHashSuiteName() + t.Cleanup(func() { require.NoError(t, SetPBinHashSuite(prev)) }) +} + +func TestPBinSetHashSuite(t *testing.T) { + pbinRestoreHashSuite(t) + + require.NoError(t, SetPBinHashSuite(PBinHashBlake3)) + require.Equal(t, PBinHashBlake3, PBinHashSuiteName()) + + require.NoError(t, SetPBinHashSuite(PBinHashKeccak)) + require.Equal(t, PBinHashKeccak, PBinHashSuiteName()) + + // An absent setting is the Keccak default, not an error. + require.NoError(t, SetPBinHashSuite("")) + require.Equal(t, PBinHashKeccak, PBinHashSuiteName()) + + require.Error(t, SetPBinHashSuite("sha256")) + require.Equal(t, PBinHashKeccak, PBinHashSuiteName(), "a rejected name must not change the suite") +} + +// The selection has to reach both seams: an engine whose node hashing and key +// derivation disagreed would build a tree no one can reproduce. +func TestPBinInitializeTrieAppliesHashSuite(t *testing.T) { + pbinRestoreHashSuite(t) + + for _, tc := range []struct { + name string + wantSame bool + }{ + {PBinHashKeccak, false}, + {PBinHashBlake3, true}, + } { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, SetPBinHashSuite(tc.name)) + trie, tree := InitializeTrieAndUpdates(ModeDirect, t.TempDir(), TrieConfig{Variant: VariantBinPatriciaTrie}) + pph, ok := trie.(*PBinPatriciaHashed) + require.True(t, ok) + defer pph.Release() + + require.Equal(t, tc.wantSame, pph.hasher.sum != nil, "node hashing seam") + require.Equal(t, tc.wantSame, pph.updateStream.keyDigest.sum != nil, "key derivation seam") + + // The buffer's hasher has to derive the same key the engine will look for. + addr := make([]byte, 20) + addr[19] = 1 + require.Equal(t, pbinKeyHasherWith(pph.hasher.sum)(addr), tree.hasher(addr)) + }) + } +} + +// With BLAKE3 selected the way a node selects it, the engine reproduces the +// reference implementation's roots. The Keccak default must NOT match the same +// vectors — otherwise the selection never reached the engine and the positive +// half proves nothing. +func TestPBinBlake3SuiteMatchesSpecRoots(t *testing.T) { + pbinRestoreHashSuite(t) + v := pbinLoadSpecVectors(t) + require.NotEmpty(t, v.Trie) + + rootOf := func(t *testing.T, tc pbinSpecTrieVector) string { + t.Helper() + trie, _ := InitializeTrieAndUpdates(ModeDirect, t.TempDir(), TrieConfig{Variant: VariantBinPatriciaTrie}) + pph := trie.(*PBinPatriciaHashed) + defer pph.Release() + pph.ResetContext(NewMockState(t)) + return pbinSpecEngineRoot(t, pph, tc) + } + + for _, tc := range v.Trie { + t.Run(tc.Name, func(t *testing.T) { + require.NoError(t, SetPBinHashSuite(PBinHashBlake3)) + require.Equal(t, tc.Root[2:], rootOf(t, tc)) + + if len(tc.Entries) == 0 { + return // the empty tree is 32 zero bytes under any hash (eip:"Node merkelization") + } + require.NoError(t, SetPBinHashSuite(PBinHashKeccak)) + require.NotEqual(t, tc.Root[2:], rootOf(t, tc), "keccak must not reproduce a blake3 reference root") + }) + } +} diff --git a/execution/commitment/pbin_hazard_test.go b/execution/commitment/pbin_hazard_test.go new file mode 100644 index 00000000000..c6c56220274 --- /dev/null +++ b/execution/commitment/pbin_hazard_test.go @@ -0,0 +1,343 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "maps" + "math/rand" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// pbinTestBatches runs the corpora through one engine and one state in order, +// the way consecutive blocks reach the trie. +func pbinTestBatches(t *testing.T, batches ...*pbinTestCorpus) (*PBinPatriciaHashed, *MockState, []byte) { + t.Helper() + pph, ms := pbinTestEngine(t) + var root []byte + for _, b := range batches { + b.applyTo(t, ms) + root = bytes.Clone(pbinTestProcess(t, pph, b.plainKeys, b.updates)) + } + return pph, ms, root +} + +// pbinTestUnion is the leaf set the batches leave behind. A key touched twice +// keeps its last value — the same last-write-wins as the oracle's duplicate-key +// insert and MockState's update merge. +func pbinTestUnion(batches ...*pbinTestCorpus) *pbinTestCorpus { + u := new(pbinTestCorpus) + for _, b := range batches { + u.plainKeys = append(u.plainKeys, b.plainKeys...) + u.updates = append(u.updates, b.updates...) + for addr, code := range b.codes { + if u.codes == nil { + u.codes = make(map[string][]byte) + } + u.codes[addr] = code + } + } + return u +} + +func (c *pbinTestCorpus) leafCount(t *testing.T) int { + t.Helper() + seen := make(map[string]struct{}) + for _, e := range c.entries(t) { + seen[string(e.key)] = struct{}{} + } + return len(seen) +} + +func (c *pbinTestCorpus) permute(order []int) *pbinTestCorpus { + out := new(pbinTestCorpus) + for _, i := range order { + out.plainKeys = append(out.plainKeys, c.plainKeys[i]) + out.updates = append(out.updates, c.updates[i]) + } + out.codes = maps.Clone(c.codes) + return out +} + +// TestPBinUntouchedSiblingSurvivesBatch: at arity 2 a cell's sibling is the +// whole other half of the subtree, so a batch that rewrites a node from the +// touched child alone loses everything under the other one. +func TestPBinUntouchedSiblingSurvivesBatch(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + batchA, batchB *pbinTestCorpus + }{ + { + name: "sibling slot in the same storage group", + batchA: new(pbinTestCorpus). + storage(pbinOracleAddr(41), pbinOracleSlot(256), 0x01). + storage(pbinOracleAddr(41), pbinOracleSlot(257), 0x02), + batchB: new(pbinTestCorpus). + storage(pbinOracleAddr(41), pbinOracleSlot(257), 0x03), + }, + { + name: "sibling under another account", + batchA: new(pbinTestCorpus). + account(pbinOracleAddr(42), 1, 10, common.Hash{0x01}). + account(pbinOracleAddr(43), 2, 20, common.Hash{0x02}), + batchB: new(pbinTestCorpus). + account(pbinOracleAddr(43), 3, 30, common.Hash{0x03}), + }, + { + name: "a third key joins a shared branch", + batchA: new(pbinTestCorpus). + storage(pbinOracleAddr(44), pbinOracleSlot(256), 0x01). + storage(pbinOracleAddr(44), pbinOracleSlot(257), 0x02), + batchB: new(pbinTestCorpus). + storage(pbinOracleAddr(44), pbinOracleSlot(258), 0x03), + }, + { + name: "one header slot of an account spanning both zones", + batchA: new(pbinTestCorpus). + account(pbinOracleAddr(45), 1, 10, common.Hash{0x01}). + storage(pbinOracleAddr(45), pbinOracleSlot(0), 0x01). + storage(pbinOracleAddr(45), pbinOracleSlot(63), 0x02). + storage(pbinOracleAddr(45), pbinOracleSlot(64), 0x03). + storage(pbinOracleAddr(45), pbinOracleSlot(1000), 0x04), + batchB: new(pbinTestCorpus). + storage(pbinOracleAddr(45), pbinOracleSlot(63), 0x09), + }, + { + name: "one of a deep-shared-prefix cluster", + batchA: pbinTestDeepSharedPrefixCorpus(), + batchB: new(pbinTestCorpus). + account(pbinOracleMinedAddrs()[1], 99, 999, common.Hash{0x99}), + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, ms, root := pbinTestBatches(t, tc.batchA, tc.batchB) + + union := pbinTestUnion(tc.batchA, tc.batchB) + require.Equal(t, union.oracleRoot(t), root) + pbinTestVerifyRecords(t, ms, root, union.leafCount(t)) + }) + } +} + +// TestPBinSplitInsideStoredPrefix: a probe diverging inside a stored branch's +// prefix shortens that prefix, and the prefix is inside the node's hash, so a +// hash carried over from the record is stale. The counter assertions pin that +// the run took that path rather than passing by luck. +func TestPBinSplitInsideStoredPrefix(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(61) + batchA := new(pbinTestCorpus). + account(addr, 1, 2, common.Hash{0x01}). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + // Sub-indices 0, 1 and 2 differ only in their last two bits, so the third slot + // leaves the stored branch's prefix one bit before its end. + batchB := new(pbinTestCorpus).storage(addr, pbinOracleSlot(258), 0x03) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(batchA.plainKeys, batchA.updates)) + pbinTestProcess(t, pph, batchA.plainKeys, batchA.updates) + afterA := pph.counters + + require.NoError(t, ms.applyPlainUpdates(batchB.plainKeys, batchB.updates)) + root := pbinTestProcess(t, pph, batchB.plainKeys, batchB.updates) + + require.Greater(t, pph.counters.splitsInsidePrefix, afterA.splitsInsidePrefix) + require.Greater(t, pph.counters.materializeReads, afterA.materializeReads, + "a branch cell read back from a record must rehash under its shortened prefix") + + union := pbinTestUnion(batchA, batchB) + require.Equal(t, union.oracleRoot(t), root) + pbinTestVerifyRecords(t, ms, root, union.leafCount(t)) +} + +// TestPBinDeepSharedPrefixCorpus uses mined keys that agree far past the root, +// so the splits happen deep instead of at the first bits, spread over batches so +// the survivors come back from records. +func TestPBinDeepSharedPrefixCorpus(t *testing.T) { + t.Parallel() + + addrs := pbinOracleMinedAddrs() + require.GreaterOrEqual(t, len(addrs), 4) + + batches := make([]*pbinTestCorpus, 0, len(addrs)) + for i, addr := range addrs { + batches = append(batches, new(pbinTestCorpus). + account(addr, uint64(i), uint64(i)*7, common.Hash{byte(i)})) + } + + pph, ms, root := pbinTestBatches(t, batches...) + require.Positive(t, pph.counters.splitsInsidePrefix) + + union := pbinTestUnion(batches...) + require.Equal(t, union.oracleRoot(t), root) + pbinTestVerifyRecords(t, ms, root, union.leafCount(t)) +} + +func pbinTestOrderings(t *testing.T, c *pbinTestCorpus) map[string]*pbinTestCorpus { + t.Helper() + + hasher := pbinKeyHasher() + treeKeys := make([][]byte, len(c.plainKeys)) + order := make([]int, len(c.plainKeys)) + for i, plainKey := range c.plainKeys { + treeKeys[i] = hasher(plainKey) + order[i] = i + } + + ascending := slices.Clone(order) + slices.SortFunc(ascending, func(a, b int) int { return bytes.Compare(treeKeys[a], treeKeys[b]) }) + descending := slices.Clone(ascending) + slices.Reverse(descending) + reversed := slices.Clone(order) + slices.Reverse(reversed) + + shuffled := slices.Clone(order) + rnd := rand.New(rand.NewSource(0x8297)) + rnd.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + + return map[string]*pbinTestCorpus{ + "as given": c, + "reversed": c.permute(reversed), + "tree key ascending": c.permute(ascending), + "tree key descending": c.permute(descending), + "shuffled": c.permute(shuffled), + } +} + +// pbinTestProcessSeq feeds the corpus one key per Process call, where +// pbinTestBatches makes a single call per corpus. +func pbinTestProcessSeq(t *testing.T, c *pbinTestCorpus) (*MockState, []byte) { + t.Helper() + pph, ms := pbinTestEngine(t) + var root []byte + for i := range c.plainKeys { + require.NoError(t, ms.applyPlainUpdates(c.plainKeys[i:i+1], c.updates[i:i+1])) + root = bytes.Clone(pbinTestProcess(t, pph, c.plainKeys[i:i+1], c.updates[i:i+1])) + } + return ms, root +} + +func pbinTestUniqueReprCorpora() []struct { + name string + corpus *pbinTestCorpus +} { + return []struct { + name string + corpus *pbinTestCorpus + }{ + { + name: "accounts", + corpus: new(pbinTestCorpus). + account(pbinOracleAddr(71), 1, 999860099, common.Hash{0x01}). + account(pbinOracleAddr(72), 3, 900234, common.Hash{0x02}). + account(pbinOracleAddr(73), 0, 0, common.Hash{}). + account(pbinOracleAddr(74), 7, 2000000000000138901, common.Hash{0x04}), + }, + { + name: "storage across both zones", + corpus: new(pbinTestCorpus). + storage(pbinOracleAddr(75), pbinOracleSlot(0), 0x01). + storage(pbinOracleAddr(75), pbinOracleSlot(63), 0x02). + storage(pbinOracleAddr(75), pbinOracleSlot(64), 0x03). + storage(pbinOracleAddr(75), pbinOracleSlot(256), 0x04). + storage(pbinOracleAddr(76), pbinOracleSlot(256), 0x05). + storage(pbinOracleAddr(76), pbinOracleSlot(257), 0x06), + }, + {name: "mixed accounts and storage", corpus: pbinTestMixedCorpus()}, + {name: "deep shared prefix", corpus: pbinTestDeepSharedPrefixCorpus()}, + } +} + +// TestPBinUniqueRepresentation ports Test_HexPatriciaHashed_UniqueRepresentation: +// the root follows the state the keys leave behind, not the order they arrive in +// nor how many Process calls they are split across. +func TestPBinUniqueRepresentation(t *testing.T) { + t.Parallel() + + for _, tc := range pbinTestUniqueReprCorpora() { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + want := tc.corpus.oracleRoot(t) + leaves := tc.corpus.leafCount(t) + + for name, ordered := range pbinTestOrderings(t, tc.corpus) { + _, batchState, batchRoot := pbinTestBatches(t, ordered) + require.Equal(t, want, batchRoot, "batch, ordering %s", name) + pbinTestVerifyRecords(t, batchState, batchRoot, leaves) + + seqState, seqRoot := pbinTestProcessSeq(t, ordered) + require.Equal(t, want, seqRoot, "sequential, ordering %s", name) + pbinTestVerifyRecords(t, seqState, seqRoot, leaves) + } + }) + } +} + +// TestPBinUniqueRepresentationAcrossRounds ports +// Test_HexPatriciaHashed_UniqueRepresentation2: a second round of updates lands +// on trees built two different ways, and both must still agree. +func TestPBinUniqueRepresentationAcrossRounds(t *testing.T) { + t.Parallel() + + addrs := [][]byte{pbinOracleAddr(81), pbinOracleAddr(82), pbinOracleAddr(83)} + round1 := new(pbinTestCorpus). + account(addrs[0], 1, 999860099, common.Hash{0x01}). + account(addrs[1], 3, 900234, common.Hash{0x02}). + storage(addrs[1], pbinOracleSlot(64), 0x01). + account(addrs[2], 0, 2000000000000138901, common.Hash{0x03}) + round2 := new(pbinTestCorpus). + account(addrs[0], 2, 2345234560099, common.Hash{0x11}). + storage(addrs[1], pbinOracleSlot(64), 0x02). + storage(addrs[1], pbinOracleSlot(1000), 0x03) + + pphBatch, batchState := pbinTestEngine(t) + pphSeq, seqState := pbinTestEngine(t) + + batchRoot := func(c *pbinTestCorpus) []byte { + require.NoError(t, batchState.applyPlainUpdates(c.plainKeys, c.updates)) + return bytes.Clone(pbinTestProcess(t, pphBatch, c.plainKeys, c.updates)) + } + seqRoot := func(c *pbinTestCorpus) []byte { + var root []byte + for i := range c.plainKeys { + require.NoError(t, seqState.applyPlainUpdates(c.plainKeys[i:i+1], c.updates[i:i+1])) + root = bytes.Clone(pbinTestProcess(t, pphSeq, c.plainKeys[i:i+1], c.updates[i:i+1])) + } + return root + } + + require.Equal(t, round1.oracleRoot(t), batchRoot(round1)) + require.Equal(t, round1.oracleRoot(t), seqRoot(round1)) + + union := pbinTestUnion(round1, round2) + root := batchRoot(round2) + require.Equal(t, union.oracleRoot(t), root) + require.Equal(t, root, seqRoot(round2)) + + pbinTestVerifyRecords(t, batchState, root, union.leafCount(t)) + pbinTestVerifyRecords(t, seqState, root, union.leafCount(t)) +} diff --git a/execution/commitment/pbin_keys.go b/execution/commitment/pbin_keys.go new file mode 100644 index 00000000000..1b46444298c --- /dev/null +++ b/execution/commitment/pbin_keys.go @@ -0,0 +1,274 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "fmt" + "sync" + + keccak "github.com/erigontech/fastkeccak" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// EIP-8297 embedding constants (eip:"Tree embedding"). +const ( + pbinBasicDataLeafKey = 0 + pbinCodeHashLeafKey = 1 + pbinDelegationLeafKey = 2 + pbinHeaderStorageOffset = 64 + pbinHeaderStorageSlots = 64 + pbinStemSubtreeWidth = 256 + + pbinAccountZone = 0x00 + pbinCodeZone = 0x01 + pbinStorageZone = 0xFF + + pbinAccountKeyLength = 34 + pbinCodeKeyLength = 34 + pbinStorageKeyLength = 66 +) + +// pbinZoneKeyLength gives the single key length a zone admits, which is what +// keeps that zone's keys prefix-free (eip:"Tree embedding"). Zones 0x02..0xFE are +// unallocated and have no length. +func pbinZoneKeyLength(zone byte) (int, bool) { + switch zone { + case pbinAccountZone: + return pbinAccountKeyLength, true + case pbinCodeZone: + return pbinCodeKeyLength, true + case pbinStorageZone: + return pbinStorageKeyLength, true + default: + return 0, false + } +} + +// pbinAddr32 widens a legacy address to the spec's Address32 (eip:"Tree embedding"). +func pbinAddr32(addr []byte) [32]byte { + if len(addr) > 32 { + panic(fmt.Sprintf("pbin: address of %d bytes exceeds 32", len(addr))) + } + var a32 [32]byte + copy(a32[32-len(addr):], addr) + return a32 +} + +// pbinTreeKey assembles zone || treePosition || subIndex. The length assert is +// what enforces the prefix-free invariant (see pbinZoneKeyLength). +func pbinTreeKey(zone byte, treePosition []byte, subIndex byte) []byte { + key := make([]byte, 0, len(treePosition)+2) + key = append(key, zone) + key = append(key, treePosition...) + key = append(key, subIndex) + + want, known := pbinZoneKeyLength(zone) + if !known { + panic(fmt.Sprintf("pbin: zone %#x names no key space", zone)) + } + if len(key) != want { + panic(fmt.Sprintf("pbin: zone %#x key of %d bytes, want %d", zone, len(key), want)) + } + return key +} + +// pbinTreeKeyAccount returns the account-header key at subIndex (eip:"Header values"). +func pbinTreeKeyAccount(addr []byte, subIndex byte) []byte { + var c pbinDigestCache + return c.accountKey(addr, subIndex) +} + +// pbinTreeKeyStorage returns the key for a storage slot: slots below 64 live in +// the account header, the rest in the storage zone (eip:"Storage"). slot is +// big-endian and at most 32 bytes. +func pbinTreeKeyStorage(addr, slot []byte) []byte { + var c pbinDigestCache + return c.storageKey(addr, slot) +} + +// PBinStorageZoneProbeSlot is the lowest slot that lives outside the account +// header (eip:"Storage"), so a proof of its key walks the account's whole +// storage-zone prefix. That is what lets a witness answer EIP-7610's +// non-empty-storage predicate for the zone, which no leaf of the account's own +// header stem can report. +func PBinStorageZoneProbeSlot() common.Hash { + var slot common.Hash + slot[length.Hash-1] = pbinHeaderStorageSlots + return slot +} + +// pbinTreeKeyCodeChunk returns the code-zone key of a chunk (eip:"Code"). +// Chunks are content-addressed by code hash, so accounts running the same +// bytecode share the leaves and no address takes part in the derivation. +func pbinTreeKeyCodeChunk(codeHash common.Hash, chunkID int) []byte { + var c pbinDigestCache + return c.codeChunkKey(codeHash, chunkID) +} + +// pbinKeyHasher returns a keyHasher deriving the primary leaf's tree key: +// BASIC_DATA for an account, the slot's own leaf for storage. The CODE_HASH +// sibling shares the stem and is written by the engine during the same visit, so +// it needs no key of its own here. +func pbinKeyHasher() keyHasher { return pbinKeyHasherWith(nil) } + +// pbinKeyHasherWith derives keys under sum, nil meaning Keccak-256. Callers swap +// the hash here and on node hashing together through setHashSuite. +// +// The digest cache is pooled rather than captured because Updates.NewEmpty copies +// the hasher value: a captured cache would be written by two buffers hashing +// concurrently. Every hit is validated against the address it was built from, so +// borrowing another goroutine's cache stays correct. +func pbinKeyHasherWith(sum pbinHashFn) keyHasher { + var pool sync.Pool + return func(plainKey []byte) []byte { + c, _ := pool.Get().(*pbinDigestCache) + if c == nil { + c = &pbinDigestCache{sum: sum} + } + key := c.treeKey(plainKey) + pool.Put(c) + return key + } +} + +// pbinDigestCache memoizes the two hash-derived key components: key_hash(addr32) +// per address and key_hash(addr32||tree_index) per 256-slot storage group +// (eip:"Storage"). The group entry is bound to the address as well as the index, so +// a changed address cannot yield a stale hit. +type pbinDigestCache struct { + sum pbinHashFn + + addr32 [32]byte + stem [32]byte + valid bool + + groupIndex [31]byte + groupHash [32]byte + groupValid bool + + buf [64]byte +} + +func (c *pbinDigestCache) hash(preimage []byte) [32]byte { + if c.sum != nil { + return c.sum(preimage) + } + return keccak.Sum256(preimage) +} + +func (c *pbinDigestCache) stemDigest(addr32 *[32]byte) *[32]byte { + if c.valid && c.addr32 == *addr32 { + return &c.stem + } + c.stem = c.hash(addr32[:]) + c.addr32 = *addr32 + c.valid = true + c.groupValid = false + return &c.stem +} + +// groupDigest hashes addr32 || tree_index, where tree_index is slot>>8 as a +// 32-byte big-endian value: a zero byte followed by the slot's top 31 bytes. +func (c *pbinDigestCache) groupDigest(addr32, slot32 *[32]byte) *[32]byte { + idx := (*[31]byte)(slot32[:31]) + if c.groupValid && c.addr32 == *addr32 && c.groupIndex == *idx { + return &c.groupHash + } + copy(c.buf[:32], addr32[:]) + c.buf[32] = 0 + copy(c.buf[33:], idx[:]) + c.groupHash = c.hash(c.buf[:]) + c.groupIndex = *idx + c.groupValid = true + return &c.groupHash +} + +func (c *pbinDigestCache) accountKey(addr []byte, subIndex byte) []byte { + addr32 := pbinAddr32(addr) + return pbinTreeKey(pbinAccountZone, c.stemDigest(&addr32)[:], subIndex) +} + +// accountHeaderStem and accountStoragePrefix are the two key-space regions an +// account owns, both fixed by its address. Removing an account is removing these +// two subtrees (eip:"Zero values and deletion"). +func (c *pbinDigestCache) accountHeaderStem(addr []byte) []byte { + addr32 := pbinAddr32(addr) + return append([]byte{pbinAccountZone}, c.stemDigest(&addr32)[:]...) +} + +func (c *pbinDigestCache) accountStoragePrefix(addr []byte) []byte { + addr32 := pbinAddr32(addr) + return append([]byte{pbinStorageZone}, c.stemDigest(&addr32)[:]...) +} + +// codeChunkKey derives the code-zone key of a chunk. The digest is not +// memoized: one contract spans at most a handful of tree indexes, and the +// cache's entries are bound to an address these keys do not have. +func (c *pbinDigestCache) codeChunkKey(codeHash common.Hash, chunkID int) []byte { + if chunkID < 0 { + panic(fmt.Sprintf("pbin: code chunk %d is negative", chunkID)) + } + var preimage [2 * length.Hash]byte + copy(preimage[:], codeHash[:]) + binary.BigEndian.PutUint64(preimage[2*length.Hash-8:], uint64(chunkID/pbinStemSubtreeWidth)) + position := c.hash(preimage[:]) + return pbinTreeKey(pbinCodeZone, position[:], byte(chunkID%pbinStemSubtreeWidth)) +} + +func (c *pbinDigestCache) storageKey(addr, slot []byte) []byte { + addr32 := pbinAddr32(addr) + slot32 := pbinSlot32(slot) + if pbinSlotInHeader(&slot32) { + return pbinTreeKey(pbinAccountZone, c.stemDigest(&addr32)[:], pbinHeaderStorageOffset+slot32[31]) + } + var position [64]byte + copy(position[:32], c.stemDigest(&addr32)[:]) + copy(position[32:], c.groupDigest(&addr32, &slot32)[:]) + return pbinTreeKey(pbinStorageZone, position[:], slot32[31]) +} + +func (c *pbinDigestCache) treeKey(plainKey []byte) []byte { + switch len(plainKey) { + case length.Addr: + return c.accountKey(plainKey, pbinBasicDataLeafKey) + case length.Addr + length.Hash: + return c.storageKey(plainKey[:length.Addr], plainKey[length.Addr:]) + default: + panic(fmt.Sprintf("pbin: plain key of %d bytes is neither an account nor a storage key", len(plainKey))) + } +} + +func pbinSlot32(slot []byte) [32]byte { + if len(slot) > 32 { + panic(fmt.Sprintf("pbin: storage slot of %d bytes exceeds 32", len(slot))) + } + var s32 [32]byte + copy(s32[32-len(slot):], slot) + return s32 +} + +func pbinSlotInHeader(slot *[32]byte) bool { + for _, b := range slot[:31] { + if b != 0 { + return false + } + } + return slot[31] < pbinHeaderStorageSlots +} diff --git a/execution/commitment/pbin_keys_test.go b/execution/commitment/pbin_keys_test.go new file mode 100644 index 00000000000..f9a05732eb7 --- /dev/null +++ b/execution/commitment/pbin_keys_test.go @@ -0,0 +1,292 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "encoding/hex" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" + + "github.com/erigontech/erigon/common/length" +) + +// pbinTestKeccak is an independent Keccak-256 (x/crypto, not the fastkeccak the +// engine uses), so the vectors below are pinned against the spec rather than +// against the code under test. +func pbinTestKeccak(t *testing.T, parts ...[]byte) []byte { + t.Helper() + h := sha3.NewLegacyKeccak256() + for _, p := range parts { + _, err := h.Write(p) + require.NoError(t, err) + } + return h.Sum(nil) +} + +func pbinTestAddr(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + require.Len(t, b, 20) + return b +} + +// pbinTestAddress32 is the spec's address20_to_address32 (eip:"Tree embedding"). +func pbinTestAddress32(addr []byte) []byte { + a := make([]byte, 32) + copy(a[32-len(addr):], addr) + return a +} + +func pbinTestBE32(v uint64) []byte { + b := make([]byte, 32) + binary.BigEndian.PutUint64(b[24:], v) + return b +} + +func pbinTestSlot(v uint64) []byte { return pbinTestBE32(v) } + +func pbinTestConcat(parts ...[]byte) []byte { + var out []byte + for _, p := range parts { + out = append(out, p...) + } + return out +} + +// Pins the derivation against the spec's test cases (eip:"Test Cases"). +func TestPBinTreeKeyEIPVectors(t *testing.T) { + t.Parallel() + + addr := pbinTestAddr(t, "0102030405060708090a0b0c0d0e0f1011121314") + addr32 := pbinTestAddress32(addr) + stem := pbinTestKeccak(t, addr32) + + t.Run("basic-data", func(t *testing.T) { + got := pbinTreeKeyAccount(addr, pbinBasicDataLeafKey) + require.Len(t, got, pbinAccountKeyLength) + require.Equal(t, pbinTestConcat([]byte{0x00}, stem, []byte{0x00}), got) + }) + + t.Run("code-hash", func(t *testing.T) { + got := pbinTreeKeyAccount(addr, pbinCodeHashLeafKey) + require.Len(t, got, pbinAccountKeyLength) + require.Equal(t, pbinTestConcat([]byte{0x00}, stem, []byte{0x01}), got) + }) + + t.Run("slot-5-in-header", func(t *testing.T) { + got := pbinTreeKeyStorage(addr, pbinTestSlot(5)) + require.Len(t, got, pbinAccountKeyLength) + require.Equal(t, pbinTestConcat([]byte{0x00}, stem, []byte{0x45}), got) + }) + + t.Run("slot-1000-in-storage-zone", func(t *testing.T) { + suffix := pbinTestKeccak(t, addr32, pbinTestBE32(3)) + got := pbinTreeKeyStorage(addr, pbinTestSlot(1000)) + require.Len(t, got, pbinStorageKeyLength) + require.Equal(t, pbinTestConcat([]byte{0xFF}, stem, suffix, []byte{0xE8}), got) + }) +} + +// Walks the header/storage-zone boundary and the group boundary. A mis-route +// there stays internally consistent, so a root-equality test cannot see it. +func TestPBinStorageZoneRouting(t *testing.T) { + t.Parallel() + + addr := pbinTestAddr(t, "cafebabe000000000000000000000000deadbeef") + addr32 := pbinTestAddress32(addr) + stem := pbinTestKeccak(t, addr32) + + for _, tc := range []struct { + name string + slot uint64 + treeIndex uint64 // storage zone only + subIndex byte + inHeader bool + }{ + {name: "slot-0", slot: 0, subIndex: 64, inHeader: true}, + {name: "slot-63-last-in-header", slot: 63, subIndex: 127, inHeader: true}, + {name: "slot-64-first-in-storage-zone", slot: 64, treeIndex: 0, subIndex: 64}, + {name: "slot-255-last-in-group-0", slot: 255, treeIndex: 0, subIndex: 255}, + {name: "slot-256-first-in-group-1", slot: 256, treeIndex: 1, subIndex: 0}, + {name: "slot-257", slot: 257, treeIndex: 1, subIndex: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got := pbinTreeKeyStorage(addr, pbinTestSlot(tc.slot)) + if tc.inHeader { + require.Len(t, got, pbinAccountKeyLength) + require.Equal(t, pbinTestConcat([]byte{0x00}, stem, []byte{tc.subIndex}), got) + return + } + suffix := pbinTestKeccak(t, addr32, pbinTestBE32(tc.treeIndex)) + require.Len(t, got, pbinStorageKeyLength) + require.Equal(t, pbinTestConcat([]byte{0xFF}, stem, suffix, []byte{tc.subIndex}), got) + }) + } +} + +func TestPBinStorageZoneKeysAreDistinct(t *testing.T) { + t.Parallel() + + addr := pbinTestAddr(t, "cafebabe000000000000000000000000deadbeef") + seen := make(map[string]uint64) + for _, slot := range []uint64{0, 1, 62, 63, 64, 65, 254, 255, 256, 257, 511, 512, 1000} { + key := string(pbinTreeKeyStorage(addr, pbinTestSlot(slot))) + if prev, ok := seen[key]; ok { + t.Fatalf("slots %d and %d derive the same tree key", prev, slot) + } + seen[key] = slot + } +} + +// For slots too large for a uint64 the tree index is a 31-byte shift of the +// slot, not arithmetic on it. +func TestPBinHighSlotRouting(t *testing.T) { + t.Parallel() + + addr := pbinTestAddr(t, "0102030405060708090a0b0c0d0e0f1011121314") + addr32 := pbinTestAddress32(addr) + stem := pbinTestKeccak(t, addr32) + + slot := make([]byte, 32) + for i := range slot { + slot[i] = byte(i + 1) + } + treeIndex := append([]byte{0x00}, slot[:31]...) + suffix := pbinTestKeccak(t, addr32, treeIndex) + + got := pbinTreeKeyStorage(addr, slot) + require.Len(t, got, pbinStorageKeyLength) + require.Equal(t, pbinTestConcat([]byte{0xFF}, stem, suffix, []byte{slot[31]}), got) +} + +// The stem digest covers the 32-byte address, not the 20-byte one. +func TestPBinAddr32Padding(t *testing.T) { + t.Parallel() + + addr := pbinTestAddr(t, "0102030405060708090a0b0c0d0e0f1011121314") + a32 := pbinAddr32(addr) + require.Equal(t, make([]byte, 12), a32[:12]) + require.Equal(t, addr, a32[12:]) + + key := pbinTreeKeyAccount(addr, pbinBasicDataLeafKey) + require.Equal(t, pbinTestKeccak(t, pbinTestAddress32(addr)), key[1:33]) + require.NotEqual(t, pbinTestKeccak(t, addr), key[1:33]) +} + +// The keyHasher contract: the primary leaf's tree key, sized 34 or 66 by zone. +func TestPBinKeyHasherPrimaryLeaf(t *testing.T) { + t.Parallel() + + hasher := pbinKeyHasher() + addr := pbinTestAddr(t, "0102030405060708090a0b0c0d0e0f1011121314") + + got := hasher(addr) + require.Len(t, got, pbinAccountKeyLength) + require.Equal(t, pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), got) + + got = hasher(pbinTestConcat(addr, pbinTestSlot(1000))) + require.Len(t, got, pbinStorageKeyLength) + require.Equal(t, pbinTreeKeyStorage(addr, pbinTestSlot(1000)), got) +} + +func TestPBinKeyHasherRejectsMalformedPlainKey(t *testing.T) { + t.Parallel() + + hasher := pbinKeyHasher() + require.Panics(t, func() { hasher(make([]byte, 33)) }) + require.Panics(t, func() { hasher(nil) }) +} + +// Two Updates buffers share one hasher value, since Updates.NewEmpty copies it. +// Under -race this fails if the hasher keeps a cache both copies can write. +func TestPBinKeyHasherSharedAcrossBuffers(t *testing.T) { + t.Parallel() + + addrs := [][]byte{ + pbinTestAddr(t, "0102030405060708090a0b0c0d0e0f1011121314"), + pbinTestAddr(t, "cafebabe000000000000000000000000deadbeef"), + } + slots := []uint64{0, 64, 256, 1000} + + base := NewUpdates(ModeDirect, t.TempDir(), pbinKeyHasher()) + clone := base.NewEmpty() + + var wg sync.WaitGroup + for _, buf := range []*Updates{base, clone} { + wg.Go(func() { + for range 50 { + for _, addr := range addrs { + assert.Equal(t, pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), buf.hashKey(addr)) + for _, slot := range slots { + plainKey := pbinTestConcat(addr, pbinTestSlot(slot)) + assert.Equal(t, pbinTreeKeyStorage(addr, pbinTestSlot(slot)), buf.hashKey(plainKey), + "addr %x slot %d", addr, slot) + } + } + } + }) + } + wg.Wait() +} + +// Interleaves addresses and slot groups through one hasher: a cache entry kept +// past its address or tree index would place a leaf under the wrong stem. +func TestPBinDigestCacheMatchesFreshDerivation(t *testing.T) { + t.Parallel() + + addrs := [][]byte{ + pbinTestAddr(t, "0102030405060708090a0b0c0d0e0f1011121314"), + pbinTestAddr(t, "cafebabe000000000000000000000000deadbeef"), + } + slots := []uint64{0, 63, 64, 255, 256, 257, 1000, 100000} + + hasher := pbinKeyHasher() + for range 3 { + for _, addr := range addrs { + require.Equal(t, pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), hasher(addr)) + for _, slot := range slots { + plainKey := pbinTestConcat(addr, pbinTestSlot(slot)) + require.Equal(t, pbinTreeKeyStorage(addr, pbinTestSlot(slot)), hasher(plainKey), + "addr %x slot %d", addr, slot) + } + } + } +} + +// The witness builder proves an account's storage zone occupied or empty by +// touching one slot in it, so the probe slot has to derive a key under the +// account's storage prefix and never a header one. +func TestPBinStorageZoneProbeSlotLandsInTheZone(t *testing.T) { + t.Parallel() + + probe := PBinStorageZoneProbeSlot() + var c pbinDigestCache + for _, s := range []string{ + "0102030405060708090a0b0c0d0e0f1011121314", + "cafebabe000000000000000000000000deadbeef", + } { + addr := pbinTestAddr(t, s) + require.Equal(t, c.accountStoragePrefix(addr), c.storageKey(addr, probe[:])[:1+length.Hash], + "the probe key has to sit under the account's storage prefix") + } +} diff --git a/execution/commitment/pbin_oracle_test.go b/execution/commitment/pbin_oracle_test.go new file mode 100644 index 00000000000..88e86a36dcd --- /dev/null +++ b/execution/commitment/pbin_oracle_test.go @@ -0,0 +1,686 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "math/rand" + "slices" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" + + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/common/length" +) + +// The reference implementation of EIP-8297's binary tree +// (eip:"Tree structure", "Node merkelization", "Insertion and deletion"), +// transcribed from the spec's Python with no optimisation — no memoised hashes, +// no shared buffers, one bit per byte — because it is the ground truth the +// engine is diffed against and has to stay recognisably the same algorithm. Its +// Keccak comes from x/crypto, not the fastkeccak the engine uses, so a hasher +// bug cannot cancel out on both sides. + +const ( + pbinOracleMaxKeyLength = 8192 + pbinOracleLeafTag = 0x00 + pbinOracleBranchTag = 0x01 +) + +type pbinOracleNode interface{ pbinOracleNodeKind() } + +type pbinOracleLeaf struct { + key []byte + value []byte +} + +// prefix holds one bit per byte, mirroring the spec's list[int]. +type pbinOracleBranch struct { + prefix []byte + left, right pbinOracleNode +} + +func (*pbinOracleLeaf) pbinOracleNodeKind() {} +func (*pbinOracleBranch) pbinOracleNodeKind() {} + +type pbinOracleTree struct { + root pbinOracleNode +} + +func pbinOracleBytesToBits(data []byte) []byte { + bits := make([]byte, 0, len(data)*8) + for _, b := range data { + for i := range 8 { + bits = append(bits, (b>>(7-i))&1) + } + } + return bits +} + +func (t *pbinOracleTree) insert(key, value []byte) { + if len(key) < 1 || len(key) > pbinOracleMaxKeyLength { + panic(fmt.Sprintf("pbin oracle: key length %d out of range", len(key))) + } + if len(value) != pbinValueLength { + panic(fmt.Sprintf("pbin oracle: value of %d bytes, want %d", len(value), pbinValueLength)) + } + if t.root == nil { + t.root = &pbinOracleLeaf{key: slices.Clone(key), value: slices.Clone(value)} + return + } + t.root = pbinOracleInsert(t.root, pbinOracleBytesToBits(key), key, value, 0) +} + +func pbinOracleInsert(node pbinOracleNode, bits, key, value []byte, depth int) pbinOracleNode { + if leaf, ok := node.(*pbinOracleLeaf); ok { + if bytes.Equal(leaf.key, key) { + leaf.value = slices.Clone(value) + return leaf + } + otherBits := pbinOracleBytesToBits(leaf.key) + limit := min(len(bits), len(otherBits)) + run := 0 + for depth+run < limit && bits[depth+run] == otherBits[depth+run] { + run++ + } + if depth+run >= limit { + panic("pbin oracle: insert violates prefix-freedom") + } + newLeaf := &pbinOracleLeaf{key: slices.Clone(key), value: slices.Clone(value)} + branch := &pbinOracleBranch{prefix: slices.Clone(bits[depth : depth+run])} + if bits[depth+run] == 0 { + branch.left, branch.right = newLeaf, leaf + } else { + branch.left, branch.right = leaf, newLeaf + } + return branch + } + + branch := node.(*pbinOracleBranch) + matched := 0 + for matched < len(branch.prefix) && depth+matched < len(bits) && bits[depth+matched] == branch.prefix[matched] { + matched++ + } + if depth+matched >= len(bits) { + panic("pbin oracle: insert violates prefix-freedom") + } + if matched == len(branch.prefix) { + split := depth + matched + if bits[split] == 0 { + branch.left = pbinOracleInsert(branch.left, bits, key, value, split+1) + } else { + branch.right = pbinOracleInsert(branch.right, bits, key, value, split+1) + } + return branch + } + + // The key diverges inside the prefix (eip:"Insertion and deletion"). The survivor keeps the + // bits after the divergence, dropping the bit the new branch consumes. + survivor := &pbinOracleBranch{ + prefix: slices.Clone(branch.prefix[matched+1:]), + left: branch.left, + right: branch.right, + } + newLeaf := &pbinOracleLeaf{key: slices.Clone(key), value: slices.Clone(value)} + newBranch := &pbinOracleBranch{prefix: slices.Clone(branch.prefix[:matched])} + if bits[depth+matched] == 0 { + newBranch.left, newBranch.right = newLeaf, survivor + } else { + newBranch.left, newBranch.right = survivor, newLeaf + } + return newBranch +} + +// pbinOracleEncodeBitPrefix is the spec's encode_bit_prefix (eip:"Node merkelization"). +func pbinOracleEncodeBitPrefix(prefix []byte) []byte { + if len(prefix) >= 1<<16 { + panic(fmt.Sprintf("pbin oracle: prefix of %d bits exceeds the encodable count", len(prefix))) + } + out := make([]byte, 2+(len(prefix)+7)/8) + binary.BigEndian.PutUint16(out, uint16(len(prefix))) + for i, bit := range prefix { + out[2+i/8] |= bit << (7 - i%8) + } + return out +} + +func pbinOracleMerkelize(node pbinOracleNode) [32]byte { + return pbinOracleMerkelizeWith(node, nil) +} + +// pbinOracleMerkelizeWith merkelizes under an explicit H. A nil sum means +// Keccak-256; the reference's vectors are replayed by passing BLAKE3. +func pbinOracleMerkelizeWith(node pbinOracleNode, sum func([]byte) [32]byte) [32]byte { + var out [32]byte + if node == nil { + return out + } + if sum != nil { + var pre []byte + switch n := node.(type) { + case *pbinOracleLeaf: + pre = append(pre, pbinOracleLeafTag) + pre = append(pre, n.key...) + pre = append(pre, n.value...) + case *pbinOracleBranch: + left := pbinOracleMerkelizeWith(n.left, sum) + right := pbinOracleMerkelizeWith(n.right, sum) + pre = append(pre, pbinOracleBranchTag) + pre = append(pre, pbinOracleEncodeBitPrefix(n.prefix)...) + pre = append(pre, left[:]...) + pre = append(pre, right[:]...) + } + return sum(pre) + } + h := sha3.NewLegacyKeccak256() + switch n := node.(type) { + case *pbinOracleLeaf: + h.Write([]byte{pbinOracleLeafTag}) + h.Write(n.key) + h.Write(n.value) + case *pbinOracleBranch: + left, right := pbinOracleMerkelize(n.left), pbinOracleMerkelize(n.right) + h.Write([]byte{pbinOracleBranchTag}) + h.Write(pbinOracleEncodeBitPrefix(n.prefix)) + h.Write(left[:]) + h.Write(right[:]) + } + copy(out[:], h.Sum(nil)) + return out +} + +func (t *pbinOracleTree) rootHash() [32]byte { return pbinOracleMerkelize(t.root) } + +type pbinOracleEntry struct { + key []byte + value []byte +} + +type pbinOracleCorpus struct { + name string + entries []pbinOracleEntry +} + +func pbinOracleRoot(entries []pbinOracleEntry) [32]byte { + var tree pbinOracleTree + for _, e := range entries { + tree.insert(e.key, e.value) + } + return tree.rootHash() +} + +func pbinOracleSharedBits(a, b []byte) int { + aBits, bBits := pbinOracleBytesToBits(a), pbinOracleBytesToBits(b) + n := 0 + for n < len(aBits) && n < len(bBits) && aBits[n] == bBits[n] { + n++ + } + return n +} + +func pbinOracleValue(seed uint64) []byte { + v := make([]byte, pbinValueLength) + binary.BigEndian.PutUint64(v, 0xA5A5A5A5A5A5A5A5) + binary.BigEndian.PutUint64(v[24:], seed) + return v +} + +func pbinOracleAddr(seed uint64) []byte { + addr := make([]byte, length.Addr) + binary.BigEndian.PutUint64(addr[12:], seed) + return addr +} + +func pbinOracleSlot(v uint64) []byte { + slot := make([]byte, length.Hash) + binary.BigEndian.PutUint64(slot[24:], v) + return slot +} + +func pbinOracleCorpora() []pbinOracleCorpus { + return []pbinOracleCorpus{ + pbinOracleCorpusEmpty(), + pbinOracleCorpusSingleKey(), + pbinOracleCorpusSplitAtBit0(), + pbinOracleCorpusSplitAtLastBit(), + pbinOracleCorpusSplitInsidePrefix(), + pbinOracleCorpusOneAccount(), + pbinOracleCorpusDeepSharedPrefix(), + } +} + +func pbinOracleCorpusEmpty() pbinOracleCorpus { + return pbinOracleCorpus{name: "empty"} +} + +func pbinOracleCorpusSingleKey() pbinOracleCorpus { + return pbinOracleCorpus{ + name: "single key", + entries: []pbinOracleEntry{ + {key: pbinTreeKeyAccount(pbinOracleAddr(1), pbinBasicDataLeafKey), value: pbinOracleValue(1)}, + }, + } +} + +// pbinOracleCorpusSplitAtBit0 diverges on the zone byte, so the root branch +// carries an empty prefix. +func pbinOracleCorpusSplitAtBit0() pbinOracleCorpus { + addr := pbinOracleAddr(2) + return pbinOracleCorpus{ + name: "split at bit 0", + entries: []pbinOracleEntry{ + {key: pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), value: pbinOracleValue(1)}, + {key: pbinTreeKeyStorage(addr, pbinOracleSlot(1000)), value: pbinOracleValue(2)}, + }, + } +} + +// pbinOracleCorpusSplitAtLastBit picks two slots in one storage group whose +// sub-indices differ in their low bit, the deepest split 528-bit keys admit. +func pbinOracleCorpusSplitAtLastBit() pbinOracleCorpus { + addr := pbinOracleAddr(3) + return pbinOracleCorpus{ + name: "split at bit 527", + entries: []pbinOracleEntry{ + {key: pbinTreeKeyStorage(addr, pbinOracleSlot(256)), value: pbinOracleValue(1)}, + {key: pbinTreeKeyStorage(addr, pbinOracleSlot(257)), value: pbinOracleValue(2)}, + }, + } +} + +// pbinOracleCorpusSplitInsidePrefix uses synthetic account-zone keys, not +// digests, so the divergence bit is exact: the third key leaves the prefix the +// first two share, forcing insert down the survivor path. +func pbinOracleCorpusSplitInsidePrefix() pbinOracleCorpus { + return pbinOracleCorpus{ + name: "split inside prefix", + entries: []pbinOracleEntry{ + {key: pbinOracleSyntheticAccountKey(0x00), value: pbinOracleValue(1)}, + {key: pbinOracleSyntheticAccountKey(0x01), value: pbinOracleValue(2)}, + {key: pbinOracleSyntheticAccountKey(0x40), value: pbinOracleValue(3)}, + }, + } +} + +func pbinOracleSyntheticAccountKey(stemByte byte) []byte { + key := make([]byte, pbinAccountKeyLength) + key[1] = stemByte + return key +} + +// pbinOracleCorpusOneAccount is the realistic shape: header leaves plus header- +// and storage-zone slots for one address, all sharing a stem. +func pbinOracleCorpusOneAccount() pbinOracleCorpus { + addr := pbinOracleAddr(4) + entries := []pbinOracleEntry{ + {key: pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), value: pbinOracleValue(1)}, + {key: pbinTreeKeyAccount(addr, pbinCodeHashLeafKey), value: pbinOracleValue(2)}, + } + for i, slot := range []uint64{0, 1, 63, 64, 65, 255, 256, 1000} { + entries = append(entries, pbinOracleEntry{ + key: pbinTreeKeyStorage(addr, pbinOracleSlot(slot)), + value: pbinOracleValue(uint64(10 + i)), + }) + } + return pbinOracleCorpus{name: "one account", entries: entries} +} + +const ( + pbinOracleMinedPrefixBits = 20 + pbinOracleMinedCluster = 4 +) + +func pbinOracleCorpusDeepSharedPrefix() pbinOracleCorpus { + entries := make([]pbinOracleEntry, 0, pbinOracleMinedCluster) + for i, addr := range pbinOracleMinedAddrs() { + entries = append(entries, pbinOracleEntry{ + key: pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), + value: pbinOracleValue(uint64(i)), + }) + } + return pbinOracleCorpus{name: "mined deep shared prefix", entries: entries} +} + +var pbinOracleMinedAddrs = sync.OnceValue(func() [][]byte { + return pbinOracleMineSharedStems(pbinOracleMinedPrefixBits, pbinOracleMinedCluster) +}) + +// pbinOracleMineSharedStems finds addresses whose account keys agree on the +// leading bits by trial: the stem is a digest, so it cannot be constructed. +func pbinOracleMineSharedStems(shared, n int) [][]byte { + const limit = 1 << 24 + var target []byte + found := make([][]byte, 0, n) + for i := uint64(0); i < limit && len(found) < n; i++ { + addr := pbinOracleAddr(i) + key := pbinTreeKeyAccount(addr, pbinBasicDataLeafKey) + if target == nil { + target, found = key, append(found, addr) + continue + } + if pbinOracleSharedBits(target, key) >= shared { + found = append(found, addr) + } + } + if len(found) < n { + panic(fmt.Sprintf("pbin oracle: found only %d of %d addresses sharing %d bits", len(found), n, shared)) + } + return found +} + +func TestPBinOracleEncodeBitPrefix(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + prefix []byte + want string + }{ + {name: "empty prefix is a bare count", prefix: nil, want: "0000"}, + {name: "one zero bit", prefix: []byte{0}, want: "000100"}, + {name: "one set bit lands in the MSB", prefix: []byte{1}, want: "000180"}, + {name: "three bits", prefix: []byte{1, 0, 1}, want: "0003a0"}, + {name: "seven bits pad low", prefix: []byte{1, 1, 1, 1, 1, 1, 1}, want: "0007fe"}, + {name: "full byte", prefix: []byte{1, 0, 1, 0, 1, 0, 1, 0}, want: "0008aa"}, + {name: "nine bits open a second byte", prefix: []byte{1, 0, 1, 0, 1, 0, 1, 0, 1}, want: "0009aa80"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, hex.EncodeToString(pbinOracleEncodeBitPrefix(tc.prefix))) + }) + } +} + +func TestPBinOracleEncodeBitPrefixLongRun(t *testing.T) { + t.Parallel() + + // 528 bits of 1: count 0x0210 followed by 66 0xFF bytes. + prefix := bytes.Repeat([]byte{1}, pbinMaxPathBits) + got := pbinOracleEncodeBitPrefix(prefix) + require.Len(t, got, 2+66) + require.Equal(t, []byte{0x02, 0x10}, got[:2]) + require.Equal(t, bytes.Repeat([]byte{0xFF}, 66), got[2:]) +} + +// The empty tree is 32 zero bytes (eip:"Node merkelization"), not the empty-MPT root the rest of +// erigon uses. +func TestPBinOracleEmptyTreeHash(t *testing.T) { + t.Parallel() + + var tree pbinOracleTree + root := tree.rootHash() + require.Equal(t, make([]byte, 32), root[:]) + require.NotEqual(t, empty.RootHash[:], root[:]) +} + +func TestPBinOracleSingleKeyRootIsLeafHash(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusSingleKey() + require.Len(t, corpus.entries, 1) + e := corpus.entries[0] + + var tree pbinOracleTree + tree.insert(e.key, e.value) + + require.IsType(t, &pbinOracleLeaf{}, tree.root, "a one-key tree's root is the leaf itself (eip:\"Tree structure\")") + + want := pbinTestKeccak(t, []byte{0x00}, e.key, e.value) + got := tree.rootHash() + require.Equal(t, want, got[:]) +} + +func TestPBinOracleTwoKeyRootIsBranchHash(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusSplitAtBit0() + require.Len(t, corpus.entries, 2) + a, b := corpus.entries[0], corpus.entries[1] + + var tree pbinOracleTree + tree.insert(a.key, a.value) + tree.insert(b.key, b.value) + + branch, ok := tree.root.(*pbinOracleBranch) + require.True(t, ok) + require.Empty(t, branch.prefix, "keys diverging at bit 0 leave the root prefix empty") + + left := pbinTestKeccak(t, []byte{0x00}, a.key, a.value) + right := pbinTestKeccak(t, []byte{0x00}, b.key, b.value) + want := pbinTestKeccak(t, []byte{0x01}, pbinOracleEncodeBitPrefix(nil), left, right) + + got := tree.rootHash() + require.Equal(t, want, got[:]) +} + +func TestPBinOracleSplitAtLastBit(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusSplitAtLastBit() + require.Len(t, corpus.entries, 2) + a, b := corpus.entries[0], corpus.entries[1] + require.Equal(t, pbinMaxPathBits-1, pbinOracleSharedBits(a.key, b.key)) + + var tree pbinOracleTree + tree.insert(a.key, a.value) + tree.insert(b.key, b.value) + + branch, ok := tree.root.(*pbinOracleBranch) + require.True(t, ok) + require.Len(t, branch.prefix, pbinMaxPathBits-1) + + left := pbinTestKeccak(t, []byte{0x00}, a.key, a.value) + right := pbinTestKeccak(t, []byte{0x00}, b.key, b.value) + want := pbinTestKeccak(t, []byte{0x01}, pbinOracleEncodeBitPrefix(branch.prefix), left, right) + + got := tree.rootHash() + require.Equal(t, want, got[:]) +} + +// Pins the shape of the split-inside-prefix branch (eip:"Insertion and deletion"): the bit the +// new branch consumes must not reappear in the survivor below it. +func TestPBinOracleSplitInsidePrefix(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusSplitInsidePrefix() + require.Len(t, corpus.entries, 3) + a, b, c := corpus.entries[0], corpus.entries[1], corpus.entries[2] + + var pair pbinOracleTree + pair.insert(a.key, a.value) + pair.insert(b.key, b.value) + pairRoot, ok := pair.root.(*pbinOracleBranch) + require.True(t, ok) + require.Len(t, pairRoot.prefix, 15, "a and b must share a prefix long enough to split inside") + + var tree pbinOracleTree + tree.insert(a.key, a.value) + tree.insert(b.key, b.value) + tree.insert(c.key, c.value) + + root, ok := tree.root.(*pbinOracleBranch) + require.True(t, ok) + require.Len(t, root.prefix, 9, "the new branch keeps the bits before the divergence") + + // c has a 1 bit where the old prefix had 0, so the new leaf takes the right + // side and the survivor keeps the left. + survivor, ok := root.left.(*pbinOracleBranch) + require.True(t, ok) + require.Len(t, survivor.prefix, 5, "the survivor drops the divergence bit itself") + require.Equal(t, pairRoot.prefix[10:], survivor.prefix) + require.IsType(t, &pbinOracleLeaf{}, root.right) +} + +func TestPBinOracleDuplicateKeyUpdatesValue(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusSplitAtBit0() + a, b := corpus.entries[0], corpus.entries[1] + updated := pbinOracleValue(0xDEAD) + + var tree pbinOracleTree + tree.insert(a.key, a.value) + tree.insert(b.key, b.value) + tree.insert(a.key, updated) + + var want pbinOracleTree + want.insert(a.key, updated) + want.insert(b.key, b.value) + + require.Equal(t, want.rootHash(), tree.rootHash()) + require.NotEqual(t, pbinOracleRoot(corpus.entries), tree.rootHash()) +} + +func TestPBinOracleRejectsInvalidInsert(t *testing.T) { + t.Parallel() + + key := pbinOracleCorpusSingleKey().entries[0].key + + t.Run("value must be 32 bytes", func(t *testing.T) { + t.Parallel() + var tree pbinOracleTree + require.Panics(t, func() { tree.insert(key, make([]byte, 31)) }) + }) + t.Run("key must be non-empty", func(t *testing.T) { + t.Parallel() + var tree pbinOracleTree + require.Panics(t, func() { tree.insert(nil, pbinOracleValue(0)) }) + }) + t.Run("key must fit MAX_KEY_LENGTH", func(t *testing.T) { + t.Parallel() + var tree pbinOracleTree + require.Panics(t, func() { tree.insert(make([]byte, pbinOracleMaxKeyLength+1), pbinOracleValue(0)) }) + }) + t.Run("a key that is a prefix of another is rejected", func(t *testing.T) { + t.Parallel() + var tree pbinOracleTree + tree.insert(key, pbinOracleValue(0)) + require.Panics(t, func() { tree.insert(key[:8], pbinOracleValue(1)) }) + }) + t.Run("a key extending another is rejected", func(t *testing.T) { + t.Parallel() + var tree pbinOracleTree + tree.insert(key[:8], pbinOracleValue(0)) + require.Panics(t, func() { tree.insert(key, pbinOracleValue(1)) }) + }) +} + +// Every corpus must satisfy the prefix-freedom insert asserts, so that a later +// differential failure is a tree bug and not a malformed corpus. +func TestPBinOracleCorporaArePrefixFree(t *testing.T) { + t.Parallel() + + for _, corpus := range pbinOracleCorpora() { + t.Run(corpus.name, func(t *testing.T) { + t.Parallel() + for i, a := range corpus.entries { + require.Contains(t, []int{pbinAccountKeyLength, pbinStorageKeyLength}, len(a.key), + "key %d has no zone-fixed length", i) + require.Len(t, a.value, pbinValueLength) + for j, b := range corpus.entries { + if i == j { + continue + } + require.False(t, bytes.HasPrefix(b.key, a.key), + "key %d is a prefix of key %d", i, j) + } + } + }) + } +} + +// The property that makes the oracle usable as ground truth: the root depends +// on the key/value set, not on the order entries arrive in. +func TestPBinOraclePermutationIndependence(t *testing.T) { + t.Parallel() + + for _, corpus := range pbinOracleCorpora() { + t.Run(corpus.name, func(t *testing.T) { + t.Parallel() + want := pbinOracleRoot(corpus.entries) + for name, order := range pbinOracleOrderings(corpus.entries) { + require.Equal(t, want, pbinOracleRoot(order), "ordering %s", name) + } + }) + } +} + +// The mined cluster has to really share a deep prefix — otherwise the corpus +// never exercises a split far from the root. +func TestPBinOracleDeepSharedPrefixCorpus(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusDeepSharedPrefix() + require.GreaterOrEqual(t, len(corpus.entries), 4) + + first := corpus.entries[0].key + for _, e := range corpus.entries[1:] { + require.GreaterOrEqual(t, pbinOracleSharedBits(first, e.key), pbinOracleMinedPrefixBits) + } + + var tree pbinOracleTree + for _, e := range corpus.entries { + tree.insert(e.key, e.value) + } + root, ok := tree.root.(*pbinOracleBranch) + require.True(t, ok) + require.GreaterOrEqual(t, len(root.prefix), pbinOracleMinedPrefixBits-1) +} + +// One account's storage-zone keys must land under a shared stem: they agree on +// the 8+256 zone+stem bits. +func TestPBinOracleStemSharedCorpus(t *testing.T) { + t.Parallel() + + corpus := pbinOracleCorpusOneAccount() + var storage [][]byte + for _, e := range corpus.entries { + if len(e.key) == pbinStorageKeyLength { + storage = append(storage, e.key) + } + } + require.GreaterOrEqual(t, len(storage), 2) + for _, k := range storage[1:] { + require.GreaterOrEqual(t, pbinOracleSharedBits(storage[0], k), 8+256) + } +} + +func pbinOracleOrderings(entries []pbinOracleEntry) map[string][]pbinOracleEntry { + byKeyAsc := slices.Clone(entries) + slices.SortFunc(byKeyAsc, func(a, b pbinOracleEntry) int { return bytes.Compare(a.key, b.key) }) + byKeyDesc := slices.Clone(byKeyAsc) + slices.Reverse(byKeyDesc) + reversed := slices.Clone(entries) + slices.Reverse(reversed) + + shuffled := slices.Clone(entries) + rnd := rand.New(rand.NewSource(0x8297)) + rnd.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + + return map[string][]pbinOracleEntry{ + "reversed": reversed, + "key ascending": byKeyAsc, + "key descending": byKeyDesc, + "shuffled": shuffled, + } +} diff --git a/execution/commitment/pbin_overflow_test.go b/execution/commitment/pbin_overflow_test.go new file mode 100644 index 00000000000..0658169636a --- /dev/null +++ b/execution/commitment/pbin_overflow_test.go @@ -0,0 +1,215 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/hex" + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// pbinTestSpecCodeChunkKey transcribes get_tree_key_for_code_chunk +// (eip:"Code") from the spec's Python, hashing with the independent Keccak the +// tests use. It is the ground truth for the cache-backed derivation. +func pbinTestSpecCodeChunkKey(t *testing.T, codeHash common.Hash, chunkID int) []byte { + t.Helper() + position := pbinTestKeccak(t, codeHash[:], pbinTestBE32(uint64(chunkID/pbinStemSubtreeWidth))) + key := append(append([]byte{pbinCodeZone}, position...), byte(chunkID%pbinStemSubtreeWidth)) + require.Len(t, key, pbinCodeKeyLength) + return key +} + +// TestPBinChunkKeyMatchesSpec pins the code embedding: every chunk is +// content-addressed by code hash, with the chunk id split into a 32-byte tree +// index and a sub-index. +func TestPBinChunkKeyMatchesSpec(t *testing.T) { + t.Parallel() + + codeHash := common.Hash{0x82, 0x97} + + for _, chunkID := range []int{ + 0, + 1, + pbinStemSubtreeWidth - 1, // the last of the first code group + pbinStemSubtreeWidth, // the first of the second + 792, // the last chunk MaxCodeSize produces + } { + t.Run(fmt.Sprintf("chunk %d", chunkID), func(t *testing.T) { + t.Parallel() + got := pbinTreeKeyCodeChunk(codeHash, chunkID) + require.Equal(t, pbinTestSpecCodeChunkKey(t, codeHash, chunkID), got) + require.Len(t, got, pbinCodeKeyLength) + require.EqualValues(t, pbinCodeZone, got[0]) + }) + } + + require.Panics(t, func() { pbinTreeKeyCodeChunk(codeHash, -1) }, + "a negative chunk id names no key") +} + +// TestPBinChunkKeyMatchesVectorIndices pins the derivation against the +// reference corpus at every chunk id the corpus carries — both sides of the +// 255/256 and 511/512 group boundaries, and the last chunk of MAX_CODE_SIZE. +func TestPBinChunkKeyMatchesVectorIndices(t *testing.T) { + e := pbinLoadConformance(t).Embedding + codeHash := common.BytesToHash(pbinUnhex(t, e.CodeHash)) + keys := pbinDigestCache{sum: pbinBlake3Hash} + + wantIDs := []int{0, 1, 255, 256, 257, 511, 512, 2114} + require.Len(t, e.CodeChunkKeys, len(wantIDs)) + for _, id := range wantIDs { + want, ok := e.CodeChunkKeys[strconv.Itoa(id)] + require.True(t, ok, "the corpus carries no chunk %d", id) + require.Equal(t, want, "0x"+hex.EncodeToString(keys.codeChunkKey(codeHash, id)), "chunk %d", id) + } +} + +// TestPBinChunkKeyIgnoresAddress: the derivation takes no address, so a digest +// cache warmed on an account stem must not leak its memoized digests into a +// chunk key. +func TestPBinChunkKeyIgnoresAddress(t *testing.T) { + t.Parallel() + + codeHash := common.Hash{0x82, 0x97} + var a, b pbinDigestCache + a.accountKey(pbinOracleAddr(60), pbinBasicDataLeafKey) + b.accountKey(pbinOracleAddr(61), pbinBasicDataLeafKey) + + for _, chunkID := range []int{0, pbinStemSubtreeWidth - 1, pbinStemSubtreeWidth, 2114} { + fresh := pbinTreeKeyCodeChunk(codeHash, chunkID) + require.Equal(t, fresh, a.codeChunkKey(codeHash, chunkID), "chunk %d", chunkID) + require.Equal(t, fresh, b.codeChunkKey(codeHash, chunkID), "chunk %d", chunkID) + } +} + +// TestPBinCodeKeyNeverRoutesToTheStorageZone pins that a code key cannot reach +// the storage zone. A chunk key derives from code_hash ‖ tree_index, a 64-byte +// preimage that is not a plain key at all, and the stream's key hasher accepts +// only the two plain-key shapes. +func TestPBinCodeKeyNeverRoutesToTheStorageZone(t *testing.T) { + t.Parallel() + + codeHash := common.Hash{0x11} + for chunkID := 0; chunkID < 600; chunkID += 37 { + key := pbinTreeKeyCodeChunk(codeHash, chunkID) + require.EqualValues(t, pbinCodeZone, key[0], "chunk %d", chunkID) + require.Len(t, key, pbinCodeKeyLength, "chunk %d", chunkID) + } + + hasher := pbinKeyHasher() + for _, plainKey := range [][]byte{ + make([]byte, pbinCodeKeyLength), // a code key handed back as a plain key + make([]byte, pbinCodeKeyLength-1), // its stem + make([]byte, 2*length.Hash), // the chunk-position preimage itself + } { + require.Panics(t, func() { hasher(plainKey) }, + "a %d-byte plain key is neither an account nor a storage key", len(plainKey)) + } +} + +// TestPBinChunksCrossGroupBoundary is the code zone end to end: chunk 256 opens +// a second code group on its own stem, and the engine commits both groups. +func TestPBinChunksCrossGroupBoundary(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + chunks int + }{ + {name: "fills group 0", chunks: pbinStemSubtreeWidth}, + {name: "one chunk into group 1", chunks: pbinStemSubtreeWidth + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(61) + code := pbinTestCode((tc.chunks-1)*pbinChunkDataLen + 1) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 3, 7, code) + require.Equal(t, 2+tc.chunks, corpus.leafCount(t)) + + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + root := pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + + require.Equal(t, corpus.oracleRoot(t), root) + pbinTestVerifyRecords(t, ms, root, corpus.leafCount(t)) + }) + } + + h := common.Hash{0xAB} + last, first := pbinTreeKeyCodeChunk(h, pbinStemSubtreeWidth-1), pbinTreeKeyCodeChunk(h, pbinStemSubtreeWidth) + require.NotEqual(t, last[1:33], first[1:33], "group 1 sits on its own stem") + require.EqualValues(t, pbinStemSubtreeWidth-1, last[33]) + require.EqualValues(t, 0, first[33], "the sub-index wraps at the group boundary") +} + +// TestPBinSharedBytecodeEmitsOneChunkSet pins the point of content addressing +// (eip:"Code"): two accounts running the same bytecode name the same code-zone +// leaves, so the zone holds one copy of them. +func TestPBinSharedBytecodeEmitsOneChunkSet(t *testing.T) { + t.Parallel() + + code := pbinTestCode((pbinStemSubtreeWidth+2)*pbinChunkDataLen - 3) + a, b := pbinOracleAddr(62), pbinOracleAddr(63) + corpus := new(pbinTestCorpus). + accountWithCodeBytes(a, 1, 10, code). + accountWithCodeBytes(b, 2, 20, code) + + chunks := len(pbinChunkifyCode(code)) + require.Equal(t, pbinStemSubtreeWidth+2, chunks) + // Two accounts: four header leaves and one shared chunk set spanning two + // code groups. + require.Equal(t, 2*2+chunks, corpus.leafCount(t)) + + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + root := pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + + require.Equal(t, corpus.oracleRoot(t), root) + pbinTestVerifyRecords(t, ms, root, corpus.leafCount(t)) +} + +// TestPBinCodeChunksFollowEveryAccountZoneKey pins where the code-zone block +// sits in the visit order: the zone byte puts it after every account-header key +// and before every storage-zone one, so the chunks of an account visited early +// have to wait for the last account of the run. +func TestPBinCodeChunksFollowEveryAccountZoneKey(t *testing.T) { + t.Parallel() + + code := pbinTestCode(5 * pbinChunkDataLen) + early := pbinOracleAddr(64) + corpus := new(pbinTestCorpus).accountWithCodeBytes(early, 1, 10, code) + for i := uint64(65); i < 70; i++ { + addr := pbinOracleAddr(i) + corpus.account(addr, i, i*2, common.Hash{byte(i)}). + storage(addr, pbinOracleSlot(7), 0x01). + storage(addr, pbinOracleSlot(4096), 0x02) + } + + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + root := pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + + require.Equal(t, corpus.oracleRoot(t), root) + pbinTestVerifyRecords(t, ms, root, corpus.leafCount(t)) +} diff --git a/execution/commitment/pbin_pathlimit_test.go b/execution/commitment/pbin_pathlimit_test.go new file mode 100644 index 00000000000..28818b3fe2f --- /dev/null +++ b/execution/commitment/pbin_pathlimit_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/db/kv" +) + +// A key path of exactly pbinMaxPathBits is decodable off the wire but cannot be +// a branch: naming either child needs one bit more than a path holds. Both +// places that descend from a branch build that child path, so an untrusted node +// set must be refused there rather than panic inside the bit-path arithmetic. + +func pbinTestFullLengthPath() pbinBitpath { + return pbinPathFromBits(bytes.Repeat([]byte{0xAA}, pbinMaxPathBits/8), pbinMaxPathBits) +} + +type pbinTestFixedBranchCtx struct { + PatriciaContext + record []byte +} + +func (c *pbinTestFixedBranchCtx) Branch([]byte) ([]byte, kv.Step, error) { return c.record, 0, nil } + +// TestPBinWitnessRefusesBranchAtMaxPath: a witness whose node at the longest +// representable path is a branch is malformed, and reading its record must say +// so instead of panicking. +func TestPBinWitnessRefusesBranchAtMaxPath(t *testing.T) { + t.Parallel() + + prefix := pbinTestFullLengthPath() + root := common.Hash{0x01} + tree := &pbinWitnessTree{ + root: root, + nodes: map[common.Hash]pbinWitnessNode{ + root: {tag: pbinBranchTag, prefix: prefix, children: [2]common.Hash{{0x02}, {0x03}}}, + }, + } + + _, _, err := pbinNewWitnessContext(tree).Branch(pbinEncodeBitPath(&prefix)) + require.ErrorIs(t, err, errPBinWitnessNode) +} + +// TestPBinMaterializeRefusesBranchAtMaxPath: a prefix decoded from a witness is +// bounded on its own, so a cell reached at 528-n bits may carry an n-bit prefix +// that lands the branch exactly at the limit. +func TestPBinMaterializeRefusesBranchAtMaxPath(t *testing.T) { + t.Parallel() + + empty := pbinNewWitnessContext(&pbinWitnessTree{nodes: map[common.Hash]pbinWitnessNode{}}) + var atRoot pbinBitpath + record, err := empty.branchRecord(&pbinWitnessNode{ + tag: pbinBranchTag, children: [2]common.Hash{{0x02}, {0x03}}, + }, &atRoot) + require.NoError(t, err) + + pph := NewPBinPatriciaHashed(&pbinTestFixedBranchCtx{record: record}) + defer pph.Release() + + var cell pbinCell + cell.reset() + cell.kind = pbinNodeBranch + cell.prefix = pbinPathFromBits([]byte{0xAA}, 8) + path := pbinPathFromBits(bytes.Repeat([]byte{0xAA}, pbinMaxPathBits/8-1), pbinMaxPathBits-8) + + require.ErrorIs(t, pph.materializeBranch(&cell, &path), errPBinCellHash) +} diff --git a/execution/commitment/pbin_patricia_hashed.go b/execution/commitment/pbin_patricia_hashed.go new file mode 100644 index 00000000000..3d86e88731f --- /dev/null +++ b/execution/commitment/pbin_patricia_hashed.go @@ -0,0 +1,964 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +// PBinPatriciaHashed — commitment over EIP-8297's partitioned binary tree. +// +// The EIP leaves its hash function open; this engine defaults to Keccak-256 for +// both node hashing and tree-key derivation. Interop runs on BLAKE3 +// (SetPBinHashSuite) — what the execution-specs reference and the other clients +// on the shared binary-trie testnets hash with; a Keccak-keyed tree agrees with +// no other client. +// +// Parallel and streaming mounting are structurally out: their prefix trie is +// nibble-shaped and the binary key space has no nibbles. + +package commitment + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math/bits" + "sync" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// PBinPatriciaHashed computes commitment over EIP-8297's partitioned binary +// tree. It borrows the hex engine's grid, unfold and fold shape and none of its +// node model: arity is 2, there is no extension node and no storage root, and a +// leaf commits its complete tree key. +type PBinPatriciaHashed struct { + grid pbinGrid + currentKey pbinBitpath // path from the root to the deepest active row, one bit per level + ctx PatriciaContext + hasher pbinHasher + branchEncoder pbinBranchEncoder + counters pbinCounters + updateStream pbinUpdateStream + + lastKey [pbinStorageKeyLength]byte // the deepest key visited so far, which the next one must exceed + lastKeyLen int16 + + traceW io.Writer + + rootChecked bool + rootTouched bool + rootPresent bool + rootPrev []byte // root record as last read or written; nil = never read +} + +// pbinCounters measures what keeping a single hash per branch cell costs: a +// probe diverging inside a stored prefix invalidates that hash, and rebuilding +// it needs a branch read the descent itself would not have made. The +// alternative, storing both child hashes per cell, is a wire-format change. +type pbinCounters struct { + splitsInsidePrefix uint64 + materializeReads uint64 +} + +// pbinPool recycles engines: the grid is the better part of a megabyte. Release +// must leave a pooled engine in the state a fresh one starts in. +var pbinPool sync.Pool + +func NewPBinPatriciaHashed(ctx PatriciaContext) *PBinPatriciaHashed { + pph, ok := pbinPool.Get().(*PBinPatriciaHashed) + if !ok { + pph = &PBinPatriciaHashed{} + } + pph.ctx = ctx + return pph +} + +func (pph *PBinPatriciaHashed) Variant() TrieVariant { return VariantBinPatriciaTrie } + +func (pph *PBinPatriciaHashed) ResetContext(ctx PatriciaContext) { pph.ctx = ctx } + +func (pph *PBinPatriciaHashed) SetTraceWriter(w io.Writer) { pph.traceW = w } + +// EnableCsvMetrics is a no-op: the binary engine collects no metrics. +func (pph *PBinPatriciaHashed) EnableCsvMetrics(string) {} + +// Reset drops the in-memory tree, keeping the context. +func (pph *PBinPatriciaHashed) Reset() { + pph.grid.resetForReuse() + pph.currentKey = pbinBitpath{} + pph.rootChecked, pph.rootTouched, pph.rootPresent = false, false, false + pph.rootPrev = nil + pph.updateStream.reset() + pph.lastKeyLen = 0 + pph.hasher.tracer = nil +} + +// setHashSuite swaps the hash on both seams at once — node hashing here and the +// returned key-derivation hasher — so neither can be configured without the +// other. nil is Keccak-256 on both. +func (pph *PBinPatriciaHashed) setHashSuite(sum pbinHashFn) keyHasher { + pph.hasher.sum = sum + pph.updateStream.keyDigest = pbinDigestCache{sum: sum} + return pbinKeyHasherWith(sum) +} + +// Release returns the engine to the pool. The caller must not use it afterwards. +func (pph *PBinPatriciaHashed) Release() { + pph.Reset() + pph.ctx = nil + pph.traceW = nil + pph.hasher.sum = nil + pph.updateStream.release() + pph.counters = pbinCounters{} + pph.branchEncoder.buf = pph.branchEncoder.buf[:0] + pbinPool.Put(pph) +} + +var ( + errPBinMissingBranch = errors.New("pbin: branch record missing") + errPBinDeleteUnsupported = errors.New("pbin: record outlived its state") + errPBinVisitOrder = errors.New("pbin: visit order is not ascending") +) + +// ErrPBinUnsupported marks a code path only the hex trie implements. Callers +// wrap it with the path name, so the bin variant refuses instead of no-opping. +var ErrPBinUnsupported = errors.New("pbin: unsupported under the bin commitment variant") + +// pbinRootKey names the record holding the root cell — the one node no descent +// can name, since every other node is found by the path that reaches it. It +// cannot collide with a node record: every pbinAppendBitPath key ends in a +// trailing bit-count byte ≤ 7. The empty key would not do — domain iteration +// reads a zero-length key as end-of-stream, and it sorts first, truncating the +// whole table. +var pbinRootKey = []byte{0x08} + +// Process folds the update stream into the tree and returns the new root. +// HashSort hands keys over in tree-key order, which is descent order, so the +// grid only ever walks the path between two consecutive keys. warmup is +// ignored: there is no parallel read path to pre-warm. +func (pph *PBinPatriciaHashed) Process(ctx context.Context, updates *Updates, logPrefix string, onProgress func(*CommitProgress), warmup WarmupConfig) ([]byte, error) { + pph.lastKeyLen = 0 + processed, err := pph.updateStream.process(ctx, updates, pph.ctx, pph.followAndUpdate) + if err != nil { + return nil, fmt.Errorf("pbin: process %s: %w", logPrefix, err) + } + for pph.grid.activeRows > 0 { + if err = pph.fold(); err != nil { + return nil, fmt.Errorf("pbin: final fold: %w", err) + } + } + if err := pph.storeRoot(); err != nil { + return nil, err + } + if onProgress != nil { + onProgress(&CommitProgress{KeyIndex: processed, UpdateCount: processed}) + } + if pph.traceW != nil { + fmt.Fprintf(pph.traceW, "pbin: keys=%d splitsInsidePrefix=%d materializeReads=%d\n", + processed, pph.counters.splitsInsidePrefix, pph.counters.materializeReads) + } + return pph.RootHash() +} + +// followAndUpdate moves the grid onto treeKey and writes the update into the +// cell that lands there. +func (pph *PBinPatriciaHashed) followAndUpdate(treeKey, plainKey []byte, update *Update) error { + probe, err := pph.seek(treeKey) + if err != nil { + return err + } + return pph.updateCell(plainKey, &probe, update) +} + +// seek moves the grid onto treeKey and returns the path to it. Visits must +// ascend: a fold writes the row's record outright, so returning to a folded row +// would rewrite it under a touch map that no longer names what the first write +// touched. +func (pph *PBinPatriciaHashed) seek(treeKey []byte) (pbinBitpath, error) { + if pph.lastKeyLen > 0 && bytes.Compare(treeKey, pph.lastKey[:pph.lastKeyLen]) <= 0 { + return pbinBitpath{}, fmt.Errorf("%w: %x after %x", errPBinVisitOrder, treeKey, pph.lastKey[:pph.lastKeyLen]) + } + pph.lastKeyLen = int16(copy(pph.lastKey[:], treeKey)) + + probe := pbinPathFromBytes(treeKey) + for !probe.hasPrefix(&pph.currentKey) { + if err := pph.fold(); err != nil { + return probe, err + } + } + for u := pph.needUnfolding(&probe); u.action != pbinUnfoldNone; u = pph.needUnfolding(&probe) { + if err := pph.unfold(&probe, u); err != nil { + return probe, err + } + } + return probe, nil +} + +// updateCell writes one leaf into the deepest open row. Unfolding has already +// made the target either empty — a new leaf, whose prefix is the rest of the +// key — or the same leaf touched again, never a branch. +func (pph *PBinPatriciaHashed) updateCell(plainKey []byte, probe *pbinBitpath, update *Update) error { + g := &pph.grid + var c *pbinCell + var depth int16 + var row int + var bit uint64 + if g.activeRows == 0 { + c = &g.root + } else { + row = g.activeRows - 1 + depth = g.depths[row] + if probe.bitLen < depth { + return fmt.Errorf("pbin: a %d-bit key cannot be updated in a row at depth %d", probe.bitLen, depth) + } + bit = probe.bit(depth - 1) + c = &g.rows[row][bit] + } + + // An absent key and a zero-valued one are the same state, so both a delete and + // a value of 32 zero bytes remove the leaf rather than store it. Code length + // therefore comes from code_size, never from which chunks are present. + drop := update.Deleted() + if !drop { + zero, err := pbinLeafValueIsZero(probe, update) + if err != nil { + return err + } + drop = zero + } + if drop { + // A probe shorter than a whole key names a subtree, and dropping it drops + // everything under it. + if c.kind == pbinNodeEmpty { + return nil + } + slot := pph.currentKey + if g.activeRows != 0 { + slot.appendBit(bit) + } + if err := pph.dropSubtreeRecords(c, &slot); err != nil { + return err + } + c.reset() + if g.activeRows == 0 { + pph.rootTouched, pph.rootPresent = true, false + return nil + } + g.touchMap[row] |= uint16(1) << bit + g.afterMap[row] &^= uint16(1) << bit + return nil + } + + if g.activeRows == 0 { + pph.rootTouched, pph.rootPresent = true, true + } else { + g.touchMap[row] |= uint16(1) << bit + g.afterMap[row] |= uint16(1) << bit + } + + switch c.kind { + case pbinNodeEmpty: + c.kind = pbinNodeLeaf + c.prefix = probe.slice(depth, probe.bitLen) + case pbinNodeLeaf: + default: + return fmt.Errorf("pbin: update for a %d-bit key lands on a branch cell", probe.bitLen) + } + + switch len(plainKey) { + case 0: + // A code chunk has no plain key: no state domain holds one, so the leaf + // carries its own value. + if _, err := pbinRecordLeafValue(update); err != nil { + return err + } + case length.Addr: + c.accountAddrLen = int16(len(plainKey)) + copy(c.accountAddr[:], plainKey) + c.loaded = c.loaded.addFlag(cellLoadAccount) + case length.Addr + length.Hash: + c.storageAddrLen = int16(len(plainKey)) + copy(c.storageAddr[:], plainKey) + c.loaded = c.loaded.addFlag(cellLoadStorage) + default: + return fmt.Errorf("pbin: plain key of %d bytes is neither an account nor a storage key", len(plainKey)) + } + c.setFromUpdate(update) + return nil +} + +// pbinLeafValueIsZero reports whether the leaf at path would hold 32 zero bytes. +// The path is the whole key, so the value is formed the same way the hasher forms +// it and the two cannot drift. +func pbinLeafValueIsZero(path *pbinBitpath, u *Update) (bool, error) { + if path.bitLen%8 != 0 { + return false, fmt.Errorf("pbin: leaf key of %d bits is not whole bytes", path.bitLen) + } + var buf [pbinStorageKeyLength]byte + key := path.appendPackedBits(buf[:0]) + value, err := pbinLeafValue(key, u) + if err != nil { + return false, err + } + return value == [pbinValueLength]byte{}, nil +} + +// RootHash hashes whatever the root cell holds: a one-key tree's root is the +// leaf itself (eip:"Tree structure") and an empty tree is 32 zero bytes +// (eip:"Node merkelization"), so +// neither shape needs a special case. +func (pph *PBinPatriciaHashed) RootHash() ([]byte, error) { + if pph.grid.activeRows != 0 { + return nil, fmt.Errorf("pbin: root hash requested with %d rows still open", pph.grid.activeRows) + } + // A run that touches no key never descends, so without this the untouched + // grid would report the empty tree. + if !pph.rootChecked { + if err := pph.loadRoot(); err != nil { + return nil, err + } + } + var path pbinBitpath + hash, err := pph.cellHash(&pph.grid.root, &path) + if err != nil { + return nil, err + } + return hash[:], nil +} + +// storeRoot persists the root cell so a later engine can find the tree: a root +// sitting under a non-empty prefix — every tree confined to one zone — is +// reachable no other way. +func (pph *PBinPatriciaHashed) storeRoot() error { + if !pph.rootTouched { + return nil + } + // An emptied tree deletes the record: zero-length is the deletion encoding, + // and the domain refuses a nil value outright. + record := []byte{} + if pph.grid.root.kind != pbinNodeEmpty { + var err error + if record, err = pbinAppendCell(nil, &pph.grid.root); err != nil { + return err + } + } + if err := pph.ctx.PutBranch(pbinRootKey, record, pph.rootPrev); err != nil { + return fmt.Errorf("pbin: write root cell: %w", err) + } + pph.rootPrev = record + return nil +} + +// loadRoot reads the stored root cell into the grid; an absent record is the +// empty tree. +func (pph *PBinPatriciaHashed) loadRoot() error { + data, _, err := pph.ctx.Branch(pbinRootKey) + if err != nil { + return fmt.Errorf("pbin: read root cell: %w", err) + } + pph.rootChecked = true + if len(data) == 0 { + pph.rootPrev = []byte{} + return nil + } + pph.rootPrev = data + pph.grid.root.reset() + pos, err := pbinDecodeCell(data, 0, &pph.grid.root) + if err != nil { + return fmt.Errorf("pbin: decode root cell: %w", err) + } + if pos != len(data) { + return fmt.Errorf("%w: %d trailing bytes after the root cell", errPBinMalformedBranch, len(data)-pos) + } + // Present though untouched: unfold reads a touched-but-absent cell as a + // deleted subtree (see unfoldBranchNode). + pph.rootPresent = true + return nil +} + +// pbinUnfoldAction is what needUnfolding tells unfold to do about one cell. +type pbinUnfoldAction uint8 + +const ( + // pbinUnfoldNone means the probe key's slot is already in the grid. + pbinUnfoldNone pbinUnfoldAction = iota + // pbinUnfoldRoot means the grid has no root cell yet: read the root record. + pbinUnfoldRoot + // pbinUnfoldRecord means the cell points straight at a stored node: read it. + pbinUnfoldRecord + // pbinUnfoldDescend means the probe key agrees with the cell's whole prefix, + // so the descent runs through it and nothing below moves. + pbinUnfoldDescend + // pbinUnfoldSplit means the probe key leaves the cell's prefix partway, so the + // node below drops a level and its prefix shrinks. + pbinUnfoldSplit +) + +// pbinUnfolding is needUnfolding's answer. Descend and Split are separate +// answers rather than one bit count because only Split shortens a stored node's +// prefix, and the prefix is inside that node's hash. +type pbinUnfolding struct { + action pbinUnfoldAction + // matched counts the cell prefix bits the probe key agrees with: the whole + // prefix for Descend, short of it for Split. + matched int16 +} + +// needUnfolding reports what the grid still needs before probe's slot is in it. +// Unlike the hex engine there is no terminator to discount and no account +// boundary to clamp to: one key space, one bit per level. +func (pph *PBinPatriciaHashed) needUnfolding(probe *pbinBitpath) pbinUnfolding { + var cell *pbinCell + var depth int16 + + if pph.grid.activeRows == 0 { + if pph.grid.root.kind == pbinNodeEmpty { + if pph.rootChecked { + return pbinUnfolding{} + } + return pbinUnfolding{action: pbinUnfoldRoot} + } + cell = &pph.grid.root + } else { + row := pph.grid.activeRows - 1 + depth = pph.grid.depths[row] + if probe.bitLen <= depth { + return pbinUnfolding{} + } + cell = &pph.grid.rows[row][probe.bit(depth-1)] + } + + if cell.kind == pbinNodeEmpty { + return pbinUnfolding{} + } + if cell.prefix.bitLen == 0 { + if cell.kind == pbinNodeBranch { + return pbinUnfolding{action: pbinUnfoldRecord} + } + return pbinUnfolding{} + } + + matched := pbinCommonPrefixBitsAt(probe, depth, &cell.prefix) + if matched < cell.prefix.bitLen { + if depth+matched == probe.bitLen { + // The probe ended inside the cell's prefix without diverging: it names + // a subtree wholly containing this node, so the cell itself is the + // probe's slot. Only a subtree drop probes short of a whole key. + return pbinUnfolding{} + } + return pbinUnfolding{action: pbinUnfoldSplit, matched: matched} + } + if cell.kind == pbinNodeLeaf { + return pbinUnfolding{} // keys are prefix-free, so probe is this leaf's key + } + return pbinUnfolding{action: pbinUnfoldDescend, matched: matched} +} + +func (pph *PBinPatriciaHashed) unfold(probe *pbinBitpath, u pbinUnfolding) error { + if u.action == pbinUnfoldNone { + return nil + } + if u.action == pbinUnfoldRoot { + return pph.loadRoot() + } + g := &pph.grid + + var upCell *pbinCell + var touched, present bool + var upDepth int16 + + if g.activeRows == 0 { + upCell = &g.root + touched, present = pph.rootTouched, pph.rootPresent + } else { + upRow := g.activeRows - 1 + upDepth = g.depths[upRow] + upBit := probe.bit(upDepth - 1) + upCell = &g.rows[upRow][upBit] + touched = g.touchMap[upRow]&(uint16(1)< 1 { + head := upCell.prefix.slice(0, consumed-1) + pph.currentKey.append(&head) + } + g.depths[row] = upDepth + consumed + g.activeRows++ + return nil +} + +// pbinUnfoldConsumed is how many of the cell's prefix bits this unfold takes: +// all of them when the probe key matched, one past the divergence when it did +// not — that extra bit is what the new row branches on. +func pbinUnfoldConsumed(u pbinUnfolding, prefix *pbinBitpath) (int16, error) { + switch u.action { + case pbinUnfoldDescend: + return prefix.bitLen, nil + case pbinUnfoldSplit: + if u.matched >= prefix.bitLen { + return 0, fmt.Errorf("pbin: %d matched bits of a %d-bit prefix is not a split", u.matched, prefix.bitLen) + } + return u.matched + 1, nil + default: + return 0, fmt.Errorf("pbin: unfold action %d consumes no prefix bits", u.action) + } +} + +// unfoldBranchNode loads the record at the current descent key into a row. The +// key is reconstructed from the parent cell's stored prefix, the only place the +// bits between the two nodes exist. deleted marks a parent cell that was touched +// and is now gone, which takes the whole subtree below it with it. +func (pph *PBinPatriciaHashed) unfoldBranchNode(row int, depth int16, deleted bool) error { + g := &pph.grid + key := pbinEncodeBitPath(&pph.currentKey) + + data, _, err := pph.ctx.Branch(key) + if err != nil { + return fmt.Errorf("pbin: read branch at %x: %w", key, err) + } + if len(data) == 0 { + return fmt.Errorf("%w at %x (%d bits)", errPBinMissingBranch, key, pph.currentKey.bitLen) + } + + _, afterMap, err := pbinDecodeBranch(data, &g.rows[row]) + if err != nil { + return fmt.Errorf("pbin: decode branch at %x: %w", key, err) + } + g.prevRecord[row] = data + // The record's own touch map is write-time bookkeeping; nothing in this run + // has touched the row yet. + if deleted { + g.touchMap[row], g.afterMap[row] = afterMap, 0 + } else { + g.touchMap[row], g.afterMap[row] = 0, afterMap + } + g.branchBefore[row] = true + g.depths[row] = depth + g.activeRows++ + return nil +} + +// fillFromUpperCell moves a cell one level down, dropping the prefix bits the +// descent has taken over; skip counts those and includes the bit the new row +// branches on. The prefix is re-cut, so the caller owes the cell a +// rehashAfterPrefixChange. +func (c *pbinCell) fillFromUpperCell(up *pbinCell, skip int16) { + c.reset() + if skip < up.prefix.bitLen { + c.prefix = up.prefix.slice(skip, up.prefix.bitLen) + } + c.kind = up.kind + c.accountAddrLen = up.accountAddrLen + if up.accountAddrLen > 0 { + c.accountAddr = up.accountAddr + } + c.storageAddrLen = up.storageAddrLen + if up.storageAddrLen > 0 { + c.storageAddr = up.storageAddr + } + c.hashLen = up.hashLen + if up.hashLen > 0 { + c.hash = up.hash + } + c.children, c.childrenSet = up.children, up.childrenSet + c.loaded = up.loaded + c.Update = up.Update +} + +// fillFromLowerCell moves the sole survivor of a collapsed row into the cell +// above, prepending the bits the row consumed: those the parent already +// descended plus the one the row branched on. +func (c *pbinCell) fillFromLowerCell(low *pbinCell, head *pbinBitpath, bit uint64) { + prefix := *head + prefix.appendBit(bit) + prefix.append(&low.prefix) + *c = *low + c.prefix = prefix +} + +// rehashAfterPrefixChange restores the invariant that a set hashLen means the +// hash covers the prefix the cell holds now. A cell that cannot re-derive is +// left stale for materializeBranch. +func (pph *PBinPatriciaHashed) rehashAfterPrefixChange(c *pbinCell) { + if c.kind != pbinNodeBranch { + return + } + if c.childrenSet { + c.hash = pph.hasher.branchHash(&c.prefix, &c.children[0], &c.children[1]) + c.hashLen = length.Hash + return + } + c.hash, c.hashLen = common.Hash{}, 0 +} + +// fold reduces currentKey by one row: it hashes what the row holds into the cell +// above and, when the row stays a branch, writes the row's record. +func (pph *PBinPatriciaHashed) fold() error { + g := &pph.grid + if g.activeRows == 0 { + return errors.New("pbin: cannot fold with no active rows") + } + row := g.activeRows - 1 + if err := pbinCheckCellMaps(g.touchMap[row], g.afterMap[row]); err != nil { + return err + } + depth := g.depths[row] + if pph.currentKey.bitLen != depth-1 { + return fmt.Errorf("pbin: row %d at depth %d folds under a %d-bit key", row, depth, pph.currentKey.bitLen) + } + + var upCell *pbinCell + var bit uint64 + var upDepth int16 + if row == 0 { + upCell = &g.root + } else { + upDepth = g.depths[row-1] + bit = pph.currentKey.bit(upDepth - 1) + upCell = &g.rows[row-1][bit] + } + + var err error + switch kind, _ := afterMapUpdateKind(g.afterMap[row]); kind { + case updateKindDelete: + err = pph.foldDelete(row, bit, upCell) + case updateKindPropagate: + err = pph.foldPropagate(row, bit, upDepth, depth, upCell) + case updateKindBranch: + err = pph.foldBranch(row, bit, upDepth, depth, upCell) + } + if err != nil { + return err + } + g.activeRows-- + g.prevRecord[row] = nil + pph.currentKey.truncate(max(upDepth-1, 0)) + return nil +} + +// foldBranch stores a row that keeps both cells as one record, keyed by the bit +// path down to the branch bit. +func (pph *PBinPatriciaHashed) foldBranch(row int, bit uint64, upDepth, depth int16, upCell *pbinCell) error { + g := &pph.grid + if n := bits.OnesCount16(g.afterMap[row]); n != 2 { + return fmt.Errorf("pbin: branch fold at row %d keeps %d cells, want 2", row, n) + } + pph.propagateTouch(row, bit) + + childPath := pph.currentKey + childPath.appendBit(0) + left, err := pph.hashRowCell(&g.rows[row][0], &childPath) + if err != nil { + return err + } + childPath.setBitAt(depth-1, 1) + right, err := pph.hashRowCell(&g.rows[row][1], &childPath) + if err != nil { + return err + } + + key := pbinEncodeBitPath(&pph.currentKey) + record, err := pph.branchEncoder.encode(g.touchMap[row], g.afterMap[row], &g.rows[row]) + if err != nil { + return err + } + if err = pph.ctx.PutBranch(key, bytes.Clone(record), g.prevRecordFor(row)); err != nil { + return fmt.Errorf("pbin: write branch at %x: %w", key, err) + } + + prefix := pph.currentKey.slice(upDepth, depth-1) + upCell.reset() + upCell.kind = pbinNodeBranch + upCell.prefix = prefix + upCell.children, upCell.childrenSet = [2]common.Hash{left, right}, true + upCell.hash = pph.hasher.branchHash(&prefix, &left, &right) + upCell.hashLen = length.Hash + return nil +} + +// foldPropagate collapses a row down to its sole survivor. The node moves up +// rather than being rewritten, so the row's own record describes nothing once +// the bits it consumed are prepended to the survivor's prefix. +func (pph *PBinPatriciaHashed) foldPropagate(row int, bit uint64, upDepth, depth int16, upCell *pbinCell) error { + g := &pph.grid + pph.propagateTouch(row, bit) + + childBit := bits.TrailingZeros16(g.afterMap[row]) + child := &g.rows[row][childBit] + + head := pph.currentKey.slice(upDepth, depth-1) + upCell.fillFromLowerCell(child, &head, uint64(childBit)) + // The row's own branch bit is part of what moves up: dropping it still hashes, + // and still gives the wrong root. + if want := depth - upDepth + child.prefix.bitLen; upCell.prefix.bitLen != want { + return fmt.Errorf("pbin: propagate at row %d formed a %d-bit prefix, want %d", row, upCell.prefix.bitLen, want) + } + pph.rehashAfterPrefixChange(upCell) + return pph.deleteRowRecord(row) +} + +// foldDelete drops a row that kept nothing, taking the record it came from with +// it. +func (pph *PBinPatriciaHashed) foldDelete(row int, bit uint64, upCell *pbinCell) error { + g := &pph.grid + if g.touchMap[row] != 0 { + if row == 0 { + pph.rootTouched, pph.rootPresent = true, false + } else { + g.touchMap[row-1] |= uint16(1) << bit + g.afterMap[row-1] &^= uint16(1) << bit + } + } + upCell.reset() + return pph.deleteRowRecord(row) +} + +// pbinDerivedContext marks a context that derives its branch records from a node +// set instead of storing them. A decoded witness is the only one, and it carries +// no node the proof paths did not need. +type pbinDerivedContext interface{ pbinRecordsAreDerived() } + +// dropSubtreeRecords deletes the stored records under a cell a subtree drop +// discards. Unfolding stops at the drop probe, so no fold ever reaches them and +// nothing else reclaims them — commitment pruning goes by step, not by +// reachability. A derived context stores nothing to reclaim and would refuse the +// preimages the sweep asks it for. +func (pph *PBinPatriciaHashed) dropSubtreeRecords(c *pbinCell, slot *pbinBitpath) error { + if c.kind != pbinNodeBranch { + return nil + } + if _, derived := pph.ctx.(pbinDerivedContext); derived { + return nil + } + + head := *slot + head.append(&c.prefix) + pending := []pbinBitpath{head} + var cells [2]pbinCell + for len(pending) > 0 { + path := pending[len(pending)-1] + pending = pending[:len(pending)-1] + + key := pbinEncodeBitPath(&path) + data, _, err := pph.ctx.Branch(key) + if err != nil { + return fmt.Errorf("pbin: read branch at %x: %w", key, err) + } + if len(data) == 0 { + return fmt.Errorf("%w at %x (%d bits)", errPBinMissingBranch, key, path.bitLen) + } + _, afterMap, err := pbinDecodeBranch(data, &cells) + if err != nil { + return fmt.Errorf("pbin: decode branch at %x: %w", key, err) + } + for bit := range cells { + if afterMap&(uint16(1)< 0 && !c.loaded.account() { + plainKey := c.accountAddr[:c.accountAddrLen] + update, err := pph.ctx.Account(plainKey) + if err != nil { + return fmt.Errorf("pbin: read account %x: %w", plainKey, err) + } + if update.Deleted() { + return fmt.Errorf("%w: %x", errPBinDeleteUnsupported, plainKey) + } + c.setFromUpdate(update) + c.loaded = c.loaded.addFlag(cellLoadAccount) + } + if c.storageAddrLen > 0 && !c.loaded.storage() { + plainKey := c.storageAddr[:c.storageAddrLen] + update, err := pph.ctx.Storage(plainKey) + if err != nil { + return fmt.Errorf("pbin: read storage %x: %w", plainKey, err) + } + if update.Deleted() { + return fmt.Errorf("%w: %x", errPBinDeleteUnsupported, plainKey) + } + c.setFromUpdate(update) + c.loaded = c.loaded.addFlag(cellLoadStorage) + } + return nil +} + +// materializeBranch rebuilds a branch cell's hash under the prefix it holds now +// by reading its own record. A split shortens a survivor's prefix without moving +// its record, so the record key is the cell's path followed by that prefix. +func (pph *PBinPatriciaHashed) materializeBranch(c *pbinCell, path *pbinBitpath) error { + nodeKey := *path + // A prefix decoded from a witness is bounded on its own, not against the depth + // it was reached at, so the sum can overflow where append would panic. A branch + // landing exactly on the limit is out too: its children need one bit more. + if int(nodeKey.bitLen)+int(c.prefix.bitLen) >= pbinMaxPathBits { + return fmt.Errorf("%w: branch at %d bits with a %d-bit prefix overflows the path", + errPBinCellHash, nodeKey.bitLen, c.prefix.bitLen) + } + nodeKey.append(&c.prefix) + key := pbinEncodeBitPath(&nodeKey) + + data, _, err := pph.ctx.Branch(key) + if err != nil { + return fmt.Errorf("pbin: read branch at %x: %w", key, err) + } + if len(data) == 0 { + return fmt.Errorf("%w at %x (%d bits)", errPBinMissingBranch, key, nodeKey.bitLen) + } + pph.counters.materializeReads++ + + var cells [2]pbinCell + if _, _, err = pbinDecodeBranch(data, &cells); err != nil { + return fmt.Errorf("pbin: decode branch at %x: %w", key, err) + } + childPath := nodeKey + childPath.appendBit(0) + left, err := pph.cellHash(&cells[0], &childPath) + if err != nil { + return err + } + childPath.setBitAt(nodeKey.bitLen, 1) + right, err := pph.cellHash(&cells[1], &childPath) + if err != nil { + return err + } + + c.children, c.childrenSet = [2]common.Hash{left, right}, true + c.hash = pph.hasher.branchHash(&c.prefix, &left, &right) + c.hashLen = length.Hash + return nil +} diff --git a/execution/commitment/pbin_process_test.go b/execution/commitment/pbin_process_test.go new file mode 100644 index 00000000000..7d55c1062a6 --- /dev/null +++ b/execution/commitment/pbin_process_test.go @@ -0,0 +1,525 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "errors" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/db/kv" +) + +// pbinTestCorpus holds plain-key updates and derives the leaf set they must +// produce, so a Process run can be diffed against the reference tree. +type pbinTestCorpus struct { + plainKeys [][]byte + updates []Update + codes map[string][]byte +} + +func (c *pbinTestCorpus) account(addr []byte, nonce, balance uint64, codeHash common.Hash) *pbinTestCorpus { + return c.accountWithCode(addr, nonce, balance, codeHash, 0) +} + +func (c *pbinTestCorpus) accountWithCode(addr []byte, nonce, balance uint64, codeHash common.Hash, codeSize uint64) *pbinTestCorpus { + u := Update{Flags: NonceUpdate | BalanceUpdate | CodeUpdate, Nonce: nonce, CodeHash: codeHash, CodeSize: codeSize} + u.Balance.SetUint64(balance) + c.plainKeys = append(c.plainKeys, bytes.Clone(addr)) + c.updates = append(c.updates, u) + return c +} + +func (c *pbinTestCorpus) accountWithCodeBytes(addr []byte, nonce, balance uint64, code []byte) *pbinTestCorpus { + c.accountWithCode(addr, nonce, balance, keccak.Sum256(code), uint64(len(code))) + if c.codes == nil { + c.codes = make(map[string][]byte) + } + c.codes[string(addr)] = bytes.Clone(code) + return c +} + +func (c *pbinTestCorpus) storage(addr, slot []byte, value ...byte) *pbinTestCorpus { + u := Update{Flags: StorageUpdate, StorageLen: int8(len(value))} + copy(u.Storage[:], value) + c.plainKeys = append(c.plainKeys, append(bytes.Clone(addr), slot...)) + c.updates = append(c.updates, u) + return c +} + +func (c *pbinTestCorpus) remove(addr []byte) *pbinTestCorpus { + c.plainKeys = append(c.plainKeys, bytes.Clone(addr)) + c.updates = append(c.updates, Update{Flags: DeleteUpdate}) + return c +} + +// entries is the leaf set the corpus stands for as a single batch. +func (c *pbinTestCorpus) entries(t *testing.T) []pbinOracleEntry { + t.Helper() + return pbinTestFinalEntries(t, c) +} + +// pbinTestFinalEntries is the leaf set the batches leave behind, stated +// independently of the engine. Within a batch only a plain key's last update +// counts, because the engine re-reads post-state; an account removal drops the +// header and storage leaves derived from the address, while content-addressed +// chunk leaves stay once a materialized account inserted them. A value of 32 +// zero bytes is the same state as an absent key, so it removes the leaf. An +// account holds exactly one of the CODE_HASH and DELEGATION leaves, decided by +// its code bytes. +func pbinTestFinalEntries(t *testing.T, batches ...*pbinTestCorpus) []pbinOracleEntry { + t.Helper() + var zero [pbinValueLength]byte + var order []string + values := make(map[string][]byte) + owners := make(map[string]string) + set := func(key []byte, value [pbinValueLength]byte, owner []byte) { + k := string(key) + if _, seen := values[k]; !seen { + order = append(order, k) + } + if value == zero { + values[k] = nil + } else { + values[k] = bytes.Clone(value[:]) + } + if owner != nil { + owners[k] = string(owner) + } + } + for _, b := range batches { + last := make(map[string]int, len(b.plainKeys)) + for i, plainKey := range b.plainKeys { + last[string(plainKey)] = i + } + // Removals first: an account's header stem sorts before every other key + // it owns, so its drop always lands before the batch's re-inserts. + for i, plainKey := range b.plainKeys { + if last[string(plainKey)] != i || len(plainKey) != length.Addr || !b.updates[i].Deleted() { + continue + } + for k, owner := range owners { + if owner == string(plainKey) { + values[k] = nil + } + } + } + for i, plainKey := range b.plainKeys { + if last[string(plainKey)] != i { + continue + } + u := &b.updates[i] + switch len(plainKey) { + case length.Addr: + if u.Deleted() { + continue + } + basic, err := pbinEncodeBasicData(u.Nonce, &u.Balance, u.CodeSize) + require.NoError(t, err) + set(pbinTreeKeyAccount(plainKey, pbinBasicDataLeafKey), basic, plainKey) + if code := b.codes[string(plainKey)]; pbinIsDelegation(code) { + set(pbinTreeKeyAccount(plainKey, pbinDelegationLeafKey), pbinEncodeDelegation(code), plainKey) + set(pbinTreeKeyAccount(plainKey, pbinCodeHashLeafKey), zero, plainKey) + } else { + set(pbinTreeKeyAccount(plainKey, pbinCodeHashLeafKey), pbinCodeHashValue(u.CodeHash), plainKey) + set(pbinTreeKeyAccount(plainKey, pbinDelegationLeafKey), zero, plainKey) + for j, chunk := range pbinChunkifyCode(code) { + set(pbinTreeKeyCodeChunk(u.CodeHash, j), chunk, nil) + } + } + case length.Addr + length.Hash: + set(pbinTreeKeyStorage(plainKey[:length.Addr], plainKey[length.Addr:]), + pbinEncodeStorageValue(u.Storage[:u.StorageLen]), plainKey[:length.Addr]) + default: + t.Fatalf("plain key of %d bytes is neither an account nor a storage key", len(plainKey)) + } + } + } + entries := make([]pbinOracleEntry, 0, len(order)) + for _, k := range order { + if values[k] != nil { + entries = append(entries, pbinOracleEntry{key: []byte(k), value: values[k]}) + } + } + return entries +} + +func (c *pbinTestCorpus) oracleRoot(t *testing.T) []byte { + t.Helper() + root := pbinOracleRoot(c.entries(t)) + return root[:] +} + +// process runs the corpus the way the domain layer would: ModeDirect, so every +// value comes back through the context rather than the update stream. +func (c *pbinTestCorpus) process(t *testing.T) (*PBinPatriciaHashed, []byte) { + t.Helper() + pph, ms := pbinTestEngine(t) + c.applyTo(t, ms) + return pph, pbinTestProcess(t, pph, c.plainKeys, c.updates) +} + +// applyTo writes the corpus into state, code included: the engine reads code +// through the context, so a code-bearing account with no code behind it is an +// invalid corpus. +func (c *pbinTestCorpus) applyTo(t *testing.T, ms *MockState) { + t.Helper() + require.NoError(t, ms.applyPlainUpdates(c.plainKeys, c.updates)) + for addr, code := range c.codes { + ms.setCode([]byte(addr), code) + } +} + +func pbinTestProcess(t *testing.T, pph *PBinPatriciaHashed, plainKeys [][]byte, updates []Update) []byte { + t.Helper() + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), plainKeys, updates) + root, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.NoError(t, err) + return root +} + +// TestPBinRootHashEmptyEngine: an empty EIP-8297 tree is 32 zero bytes +// (eip:"Node merkelization"), not the empty-MPT root the rest of erigon reaches for. +func TestPBinRootHashEmptyEngine(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + root, err := pph.RootHash() + require.NoError(t, err) + require.Equal(t, make([]byte, length.Hash), root) + require.NotEqual(t, empty.RootHash[:], root) +} + +// TestPBinProcessSingleKeyRootIsLeaf pins eip:"Tree structure": with one entry the root +// is the leaf itself, not a branch wrapping it. +func TestPBinProcessSingleKeyRootIsLeaf(t *testing.T) { + t.Parallel() + + addr, slot := pbinOracleAddr(1), pbinOracleSlot(1000) + corpus := new(pbinTestCorpus).storage(addr, slot, 0x01, 0x02) + + pph, root := corpus.process(t) + require.Equal(t, pbinNodeLeaf, pph.grid.root.kind) + + value := pbinEncodeStorageValue([]byte{0x01, 0x02}) + want := pbinTestKeccak(t, []byte{0x00}, pbinTreeKeyStorage(addr, slot), value[:]) + require.Equal(t, want, root) + require.Equal(t, corpus.oracleRoot(t), root) +} + +func TestPBinProcessTwoKeysRootIsBranch(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(2) + a, b := pbinOracleSlot(256), pbinOracleSlot(257) + corpus := new(pbinTestCorpus).storage(addr, a, 0xAA).storage(addr, b, 0xBB) + + pph, root := corpus.process(t) + require.Equal(t, pbinNodeBranch, pph.grid.root.kind) + + // The two sub-indices differ only in their low bit, so the branch prefix is + // every bit of the key but the last, and slot 256 takes the left side. + left := pbinEncodeStorageValue([]byte{0xAA}) + right := pbinEncodeStorageValue([]byte{0xBB}) + leftHash := pbinTestKeccak(t, []byte{0x00}, pbinTreeKeyStorage(addr, a), left[:]) + rightHash := pbinTestKeccak(t, []byte{0x00}, pbinTreeKeyStorage(addr, b), right[:]) + prefix := pbinOracleBytesToBits(pbinTreeKeyStorage(addr, a))[:pbinMaxPathBits-1] + want := pbinTestKeccak(t, []byte{0x01}, pbinOracleEncodeBitPrefix(prefix), leftHash, rightHash) + + require.Equal(t, want, root) + require.Equal(t, corpus.oracleRoot(t), root) +} + +func TestPBinProcessMatchesOracle(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + corpus *pbinTestCorpus + }{ + { + name: "one account", + corpus: new(pbinTestCorpus).account(pbinOracleAddr(1), 3, 7, common.Hash{0xC0, 0xDE}), + }, + { + name: "accounts only", + corpus: new(pbinTestCorpus). + account(pbinOracleAddr(1), 1, 100, common.Hash{0x01}). + account(pbinOracleAddr(2), 0, 0, common.Hash{}). + account(pbinOracleAddr(3), 1<<40, 1<<62, empty.CodeHash), + }, + { + name: "storage zone only", + corpus: new(pbinTestCorpus). + storage(pbinOracleAddr(4), pbinOracleSlot(64), 0x01). + storage(pbinOracleAddr(4), pbinOracleSlot(255), 0x02, 0x03). + storage(pbinOracleAddr(4), pbinOracleSlot(256), 0x04). + storage(pbinOracleAddr(4), pbinOracleSlot(1000), bytes.Repeat([]byte{0xEE}, 32)...), + }, + { + name: "header zone slots", + corpus: new(pbinTestCorpus). + storage(pbinOracleAddr(5), pbinOracleSlot(0), 0x01). + storage(pbinOracleAddr(5), pbinOracleSlot(1), 0x02). + storage(pbinOracleAddr(5), pbinOracleSlot(63), 0x03), + }, + { + name: "one account across both zones", + corpus: new(pbinTestCorpus). + account(pbinOracleAddr(6), 9, 1234, common.Hash{0xAB}). + storage(pbinOracleAddr(6), pbinOracleSlot(0), 0x01). + storage(pbinOracleAddr(6), pbinOracleSlot(63), 0x02). + storage(pbinOracleAddr(6), pbinOracleSlot(64), 0x03). + storage(pbinOracleAddr(6), pbinOracleSlot(65), 0x04). + storage(pbinOracleAddr(6), pbinOracleSlot(1000), 0x05), + }, + { + name: "mixed accounts and storage", + corpus: pbinTestMixedCorpus(), + }, + { + name: "deep shared prefix", + corpus: pbinTestDeepSharedPrefixCorpus(), + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, root := tc.corpus.process(t) + require.Equal(t, tc.corpus.oracleRoot(t), root) + }) + } +} + +func pbinTestMixedCorpus() *pbinTestCorpus { + c := new(pbinTestCorpus) + for i := uint64(1); i <= 6; i++ { + addr := pbinOracleAddr(i) + c.account(addr, i, i*1000, common.Hash{byte(i)}) + for _, slot := range []uint64{0, 5, 63, 64, 255, 256, 1000, 1 << 20} { + c.storage(addr, pbinOracleSlot(slot), byte(i), byte(slot)) + } + } + return c +} + +// pbinTestDeepSharedPrefixCorpus uses mined addresses, so the descent walks far +// past the root before diverging. +func pbinTestDeepSharedPrefixCorpus() *pbinTestCorpus { + c := new(pbinTestCorpus) + for i, addr := range pbinOracleMinedAddrs() { + c.account(addr, uint64(i), uint64(i)*7, common.Hash{byte(i)}) + } + return c +} + +// TestPBinProcessAccountFansOutToCodeHash: one account update produces both +// BASIC_DATA and CODE_HASH, written during the same stem visit so the shared +// keyHasher stays a one-key function. +func TestPBinProcessAccountFansOutToCodeHash(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(11) + codeHash := common.Hash{0xC0, 0xDE, 0xFF} + corpus := new(pbinTestCorpus).account(addr, 5, 999, codeHash) + + pph, root := corpus.process(t) + require.Equal(t, pbinNodeBranch, pph.grid.root.kind, "two leaves under one stem make a branch") + require.Equal(t, corpus.oracleRoot(t), root) + + basic, err := pbinEncodeBasicData(5, &corpus.updates[0].Balance, 0) + require.NoError(t, err) + basicOnly := pbinOracleRoot([]pbinOracleEntry{ + {key: pbinTreeKeyAccount(addr, pbinBasicDataLeafKey), value: basic[:]}, + }) + require.NotEqual(t, basicOnly[:], root, "dropping the CODE_HASH leaf must change the root") + + code := pbinCodeHashValue(codeHash) + require.Equal(t, codeHash[:], code[:]) +} + +// TestPBinProcessStreamDeleteOnAbsentKeyIsNoop: a delete for a key that has no +// leaf removes nothing, so the tree it leaves is the empty one. +func TestPBinProcessStreamDeleteOnAbsentKeyIsNoop(t *testing.T) { + t.Parallel() + + pph, ms := pbinTestEngine(t) + plainKeys := [][]byte{pbinOracleAddr(1)} + updates := []Update{{Flags: DeleteUpdate}} + require.NoError(t, ms.applyPlainUpdates(plainKeys, updates)) + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), plainKeys, updates) + root, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.NoError(t, err) + require.Equal(t, make([]byte, length.Hash), root) +} + +// TestPBinProcessMissingStateIsAbsent: a context read for a key with no state +// reports DeleteUpdate, which means "no leaf here" and must not be mistaken for +// a removal. +func TestPBinProcessMissingStateIsAbsent(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(21) + present := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + + touched := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02). + storage(addr, pbinOracleSlot(258), 0x03). + account(pbinOracleAddr(22), 1, 2, common.Hash{0x03}) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(present.plainKeys, present.updates)) + + root := pbinTestProcess(t, pph, touched.plainKeys, touched.updates) + require.Equal(t, present.oracleRoot(t), root, "keys with no state contribute no leaf") +} + +// The neighbouring case — an absent read over a live leaf — is in +// pbin_zerovalue_test.go. + +// TestPBinProcessRepeatedKeyKeepsOneLeaf: a key rewritten by a later batch +// updates its leaf instead of splitting the stem. +func TestPBinProcessRepeatedKeyKeepsOneLeaf(t *testing.T) { + t.Parallel() + + addr, slot := pbinOracleAddr(31), pbinOracleSlot(1000) + pph, ms := pbinTestEngine(t) + + first := new(pbinTestCorpus).storage(addr, slot, 0x01) + require.NoError(t, ms.applyPlainUpdates(first.plainKeys, first.updates)) + require.Equal(t, first.oracleRoot(t), pbinTestProcess(t, pph, first.plainKeys, first.updates)) + + second := new(pbinTestCorpus).storage(addr, slot, 0x02) + require.NoError(t, ms.applyPlainUpdates(second.plainKeys, second.updates)) + root := pbinTestProcess(t, pph, second.plainKeys, second.updates) + + require.Equal(t, pbinNodeLeaf, pph.grid.root.kind) + require.Equal(t, second.oracleRoot(t), root) +} + +func TestPBinProcessEmptyUpdatesKeepsEmptyRoot(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + root := pbinTestProcess(t, pph, nil, nil) + require.Equal(t, make([]byte, length.Hash), root) +} + +var errPBinTestContext = errors.New("pbin test: context failure") + +// pbinFailingContext fails one context call after letting skip of them through, +// so a single read or write can be checked to reach the caller instead of being +// swallowed. +type pbinFailingContext struct { + PatriciaContext + method string + skip int + seen int +} + +func (c *pbinFailingContext) trip(method string) error { + if c.method != method { + return nil + } + c.seen++ + if c.seen <= c.skip { + return nil + } + return errPBinTestContext +} + +func (c *pbinFailingContext) Branch(prefix []byte) ([]byte, kv.Step, error) { + if err := c.trip("Branch"); err != nil { + return nil, 0, err + } + return c.PatriciaContext.Branch(prefix) +} + +func (c *pbinFailingContext) PutBranch(prefix, data, prevData []byte) error { + if err := c.trip("PutBranch"); err != nil { + return err + } + return c.PatriciaContext.PutBranch(prefix, data, prevData) +} + +func (c *pbinFailingContext) Account(plainKey []byte) (*Update, error) { + if err := c.trip("Account"); err != nil { + return nil, err + } + return c.PatriciaContext.Account(plainKey) +} + +func (c *pbinFailingContext) Storage(plainKey []byte) (*Update, error) { + if err := c.trip("Storage"); err != nil { + return nil, err + } + return c.PatriciaContext.Storage(plainKey) +} + +// TestPBinProcessSurfacesContextErrors runs a second batch over a stored tree, +// because only that path reads the root cell, a node record and a leaf's state, +// and fails one call at a time. +func TestPBinProcessSurfacesContextErrors(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(81) + stored := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02). + account(pbinOracleAddr(82), 3, 4, common.Hash{0x82}) + touch := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(258), 0x03). + account(pbinOracleAddr(82), 5, 6, common.Hash{0x82}) + + for _, tc := range []struct { + name string + method string + skip int + }{ + {"root cell read", "Branch", 0}, + {"node record read", "Branch", 1}, + {"storage state read", "Storage", 0}, + {"account state read", "Account", 0}, + {"node record write", "PutBranch", 0}, + {"root cell write", "PutBranch", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + require.NoError(t, ms.applyPlainUpdates(touch.plainKeys, touch.updates)) + + pph.Reset() + pph.ResetContext(&pbinFailingContext{PatriciaContext: ms, method: tc.method, skip: tc.skip}) + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), touch.plainKeys, touch.updates) + _, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.ErrorIs(t, err, errPBinTestContext) + }) + } +} diff --git a/execution/commitment/pbin_reclaim_test.go b/execution/commitment/pbin_reclaim_test.go new file mode 100644 index 00000000000..39ca8ebd06e --- /dev/null +++ b/execution/commitment/pbin_reclaim_test.go @@ -0,0 +1,125 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// Code reclamation on account removal. A removed account's chunk leaves are +// dropped only if they were absent from the parent state and no account in the +// batch's post-state holds the code hash — and both together mean the leaves +// were never inserted, since an account created and destroyed inside one batch +// merges to a bare deletion before the stream sees it. So the engine keeps +// every chunk leaf it holds, and these tests pin the three directions of that +// rule. + +// pbinMergedRemoval is the update an in-batch create-and-destroy merges to: a +// bare deletion still carrying the code fields the create touched. The stream +// must treat it as codeless — the account's code is gone from the code domain. +func pbinMergedRemoval(code []byte) Update { + return Update{Flags: DeleteUpdate, CodeHash: keccak.Sum256(code), CodeSize: uint64(len(code))} +} + +func pbinTestProcessMerged(t *testing.T, pph *PBinPatriciaHashed, plainKeys [][]byte, updates []Update) []byte { + t.Helper() + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), plainKeys, updates) + root, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.NoError(t, err) + return root +} + +func pbinTestChunkEntries(entries []pbinOracleEntry, code []byte) []pbinOracleEntry { + codeHash := keccak.Sum256(code) + for i, chunk := range pbinChunkifyCode(code) { + entries = append(entries, pbinOracleEntry{key: pbinTreeKeyCodeChunk(codeHash, i), value: bytes.Clone(chunk[:])}) + } + return entries +} + +func TestPBinReclaimDropsCodeWithNoSurvivor(t *testing.T) { + t.Parallel() + + bystander := pbinOracleAddr(91) + code := bytes.Repeat([]byte{0x5B}, 31*3) + stored := new(pbinTestCorpus).account(bystander, 1, 2, common.Hash{0x91}) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + + plainKeys := append([][]byte{pbinOracleAddr(92)}, stored.plainKeys...) + updates := append([]Update{pbinMergedRemoval(code)}, stored.updates...) + root := pbinTestProcessMerged(t, pph, plainKeys, updates) + + require.Equal(t, stored.oracleRoot(t), root, + "a code deployed and destroyed inside the batch leaves no chunk leaf") + + withChunks := pbinOracleRoot(pbinTestChunkEntries(stored.entries(t), code)) + require.NotEqual(t, withChunks[:], root, "keeping the dead code's chunks must change the root") +} + +func TestPBinReclaimKeepsCodeForBatchSibling(t *testing.T) { + t.Parallel() + + sibling := pbinOracleAddr(93) + code := bytes.Repeat([]byte{0x5B}, 31*3) + survivors := new(pbinTestCorpus).accountWithCodeBytes(sibling, 1, 5, code) + + pph, ms := pbinTestEngine(t) + survivors.applyTo(t, ms) + + plainKeys := append([][]byte{pbinOracleAddr(94)}, survivors.plainKeys...) + updates := append([]Update{pbinMergedRemoval(code)}, survivors.updates...) + root := pbinTestProcessMerged(t, pph, plainKeys, updates) + + require.Equal(t, survivors.oracleRoot(t), root, + "a sibling written in the same batch keeps the shared chunk set") + + noChunks := new(pbinTestCorpus).accountWithCode(sibling, 1, 5, keccak.Sum256(code), uint64(len(code))) + require.NotEqual(t, noChunks.oracleRoot(t), root, "the surviving holder's chunks must stay in the tree") +} + +// TestPBinReclaimKeepsCodeForPreexistingHolder is the case a referenced-set +// rule gets wrong: the code hash never appears in the removal batch except on +// the deletion itself, and the untouched holder's leaves must survive. +func TestPBinReclaimKeepsCodeForPreexistingHolder(t *testing.T) { + t.Parallel() + + holder := pbinOracleAddr(95) + code := bytes.Repeat([]byte{0x5B}, 31*3) + stored := new(pbinTestCorpus).accountWithCodeBytes(holder, 2, 9, code) + + pph, ms := pbinTestEngine(t) + stored.applyTo(t, ms) + before := pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + pph.Reset() + root := pbinTestProcessMerged(t, pph, [][]byte{pbinOracleAddr(96)}, []Update{pbinMergedRemoval(code)}) + + require.Equal(t, before, root, "an untouched pre-existing holder keeps its code") + require.Equal(t, stored.oracleRoot(t), root) + + noChunks := new(pbinTestCorpus).accountWithCode(holder, 2, 9, keccak.Sum256(code), uint64(len(code))) + require.NotEqual(t, noChunks.oracleRoot(t), root, "dropping the holder's chunks must change the root") +} diff --git a/execution/commitment/pbin_rootkey_test.go b/execution/commitment/pbin_rootkey_test.go new file mode 100644 index 00000000000..ab55cf99593 --- /dev/null +++ b/execution/commitment/pbin_rootkey_test.go @@ -0,0 +1,124 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/dbcfg" + "github.com/erigontech/erigon/db/kv/memdb" +) + +// pbinTestStoredTree runs a small corpus through the engine and returns the +// state it persisted plus the root it computed. +func pbinTestStoredTree(t *testing.T) (*MockState, []byte) { + t.Helper() + corpus := new(pbinTestCorpus). + storage(pbinOracleAddr(1), pbinOracleSlot(64), 0x01). + storage(pbinOracleAddr(1), pbinOracleSlot(1000), 0x02, 0x03) + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + return ms, pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) +} + +// Every record a Process run writes, the root record included, must survive a +// round-trip through the real TblCommitmentVals table. Domain iteration treats a +// zero-length key as end-of-stream and the empty key sorts first, so a root +// record stored under it truncates the iteration and the datadir reads back as +// fresh. +func TestPBinRootRecordRealTableIteration(t *testing.T) { + t.Parallel() + + ms, _ := pbinTestStoredTree(t) + rootRecord := bytes.Clone(ms.cm[string(pbinRootKey)]) + require.NotEmpty(t, rootRecord) + + db := memdb.NewTestDB(t, dbcfg.ChainDB) + tx := memdb.BeginRw(t, db) + for key, record := range ms.cm { + require.NoError(t, tx.Put(kv.TblCommitmentVals, []byte(key), record)) + } + + cursor, err := tx.Cursor(kv.TblCommitmentVals) + require.NoError(t, err) + defer cursor.Close() + + var gotRoot []byte + seen := 0 + for k, v, err := cursor.First(); k != nil; k, v, err = cursor.Next() { + require.NoError(t, err) + require.NotEmpty(t, k, "a zero-length key reads as end-of-stream in domain iteration") + if bytes.Equal(k, pbinRootKey) { + gotRoot = bytes.Clone(v) + } + seen++ + } + require.Equal(t, len(ms.cm), seen, "iteration truncated: not every stored record came back") + require.Equal(t, rootRecord, gotRoot, "root record lost or damaged by the table round-trip") +} + +// Every pbinAppendBitPath encoding ends in a trailing bit-count byte ≤ 7, so a +// single byte ≥ 0x08 cannot collide with any encoded path, and pbinDecodeBitPath +// must reject it outright. +func TestPBinRootKeySentinelNotABitPath(t *testing.T) { + t.Parallel() + + require.Len(t, pbinRootKey, 1) + require.GreaterOrEqual(t, pbinRootKey[0], byte(0x08)) + + _, err := pbinDecodeBitPath(pbinRootKey) + require.Error(t, err, "a canonical bit-path key must never spell the root key") + + for bitLen := int16(0); bitLen <= pbinMaxPathBits; bitLen++ { + for _, fill := range []byte{0x00, 0xFF} { + path := pbinPathFromBits(bytes.Repeat([]byte{fill}, (int(bitLen)+7)/8), bitLen) + encoded := pbinEncodeBitPath(&path) + require.NotEqual(t, pbinRootKey, encoded, "bit length %d fill %#x collides with the root key", bitLen, fill) + require.LessOrEqual(t, encoded[len(encoded)-1], byte(0x07)) + } + } +} + +// loadRoot must tell a fresh datadir from a persisted tree: no record reads back +// as the empty tree, a stored record as the root it was built with. +func TestPBinLoadRootNoRecordVersusStoredTree(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + require.NoError(t, pph.loadRoot()) + require.True(t, pph.rootChecked) + require.False(t, pph.rootPresent) + require.Equal(t, pbinNodeEmpty, pph.grid.root.kind) + root, err := pph.RootHash() + require.NoError(t, err) + require.Equal(t, make([]byte, length.Hash), root) + + ms, storedRoot := pbinTestStoredTree(t) + fresh := NewPBinPatriciaHashed(ms) + require.NoError(t, fresh.loadRoot()) + require.True(t, fresh.rootPresent) + require.NotEqual(t, pbinNodeEmpty, fresh.grid.root.kind) + reloaded, err := fresh.RootHash() + require.NoError(t, err) + require.Equal(t, storedRoot, reloaded) + require.NotEqual(t, make([]byte, length.Hash), reloaded) +} diff --git a/execution/commitment/pbin_specengine_test.go b/execution/commitment/pbin_specengine_test.go new file mode 100644 index 00000000000..f9b4d212a9f --- /dev/null +++ b/execution/commitment/pbin_specengine_test.go @@ -0,0 +1,116 @@ +package commitment + +import ( + "encoding/binary" + "encoding/hex" + "sort" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// Drives the engine itself over the reference's root vectors, rather than the oracle. + +type pbinEngineLeaf struct { + treeKey []byte + plainKey []byte + update Update +} + +// pbinLeafFromVector maps a raw (key, value) vector onto the Update the engine +// reads for that key's position: the engine rebuilds a leaf's value from Update +// fields, so a raw 32-byte value has to land on the field it will be read from. +func pbinLeafFromVector(key, value []byte, seq int) pbinEngineLeaf { + var l pbinEngineLeaf + l.treeKey = key + + // The plain key is synthetic: tree keys are digests and cannot be inverted. + // Only its length is read, to decide which cell field holds it. + account := make([]byte, length.Addr) + binary.BigEndian.PutUint32(account, uint32(seq)) + storage := make([]byte, length.Addr+length.Hash) + binary.BigEndian.PutUint32(storage, uint32(seq)) + + storageLeaf := func() { + l.plainKey = storage + l.update.Flags = StorageUpdate + l.update.StorageLen = int8(copy(l.update.Storage[:], value)) + } + // A leaf carrying its own 32 bytes has no plain key: a code chunk, or a + // reserved sub-index with no defined packing. + recordLeaf := func() { + l.plainKey = nil + l.update.Flags = StorageUpdate + l.update.StorageLen = int8(copy(l.update.Storage[:], value)) + } + + if key[0] == pbinStorageZone { + storageLeaf() + return l + } + if key[0] == pbinCodeZone { + recordLeaf() + return l + } + switch sub := key[len(key)-1]; { + case sub == pbinBasicDataLeafKey: + l.plainKey = account + l.update.Flags = BalanceUpdate | NonceUpdate + l.update.CodeSize = uint64(binary.BigEndian.Uint32(value[pbinBasicDataCodeSizeOffset:])) + l.update.Nonce = binary.BigEndian.Uint64(value[pbinBasicDataNonceOffset:]) + l.update.Balance = *new(uint256.Int).SetBytes(value[pbinBasicDataBalanceOffset:]) + return l + case sub == pbinCodeHashLeafKey: + l.plainKey = account + l.update.Flags = CodeUpdate + l.update.CodeHash = common.BytesToHash(value) + return l + case sub >= pbinHeaderStorageOffset && sub < pbinHeaderStorageOffset+pbinHeaderStorageSlots: + storageLeaf() + return l + default: + recordLeaf() + return l + } +} + +func pbinSpecEngineRoot(t *testing.T, pph *PBinPatriciaHashed, tc pbinSpecTrieVector) string { + t.Helper() + leaves := make([]pbinEngineLeaf, len(tc.Entries)) + for i, e := range tc.Entries { + leaves[i] = pbinLeafFromVector(pbinMustHex(t, e.Key), pbinMustHex(t, e.Value), i+1) + } + sort.Slice(leaves, func(i, j int) bool { + return string(leaves[i].treeKey) < string(leaves[j].treeKey) + }) + + for i := range leaves { + require.NoError(t, pph.followAndUpdate(leaves[i].treeKey, leaves[i].plainKey, &leaves[i].update), + "insert %x", leaves[i].treeKey) + } + for pph.grid.activeRows > 0 { + require.NoError(t, pph.fold()) + } + require.NoError(t, pph.storeRoot()) + got, err := pph.RootHash() + require.NoError(t, err) + return hex.EncodeToString(got) +} + +// Not parallel: it inspects engines coming out of the shared pool. +func TestPBinReleaseClearsHashSuite(t *testing.T) { + pph := NewPBinPatriciaHashed(NewMockState(t)) + pph.setHashSuite(pbinBlake3Hash) + require.NotNil(t, pph.hasher.sum) + + pph.Release() + require.Nil(t, pph.hasher.sum, "Release must drop the hash override before pooling") + + reused := NewPBinPatriciaHashed(NewMockState(t)) + require.Nil(t, reused.hasher.sum, "a pooled engine must start on the Keccak default") + reused.Release() +} diff --git a/execution/commitment/pbin_specroots_test.go b/execution/commitment/pbin_specroots_test.go new file mode 100644 index 00000000000..9b4e3b2386b --- /dev/null +++ b/execution/commitment/pbin_specroots_test.go @@ -0,0 +1,93 @@ +package commitment + +import ( + "encoding/hex" + "sort" + "testing" + + "github.com/stretchr/testify/require" + "lukechampine.com/blake3" + + "github.com/erigontech/erigon/common" +) + +// Replays the reference's root vectors (see pbinSpecVectors) against the oracle +// under BLAKE3. The reference rebuilds the tree canonically while the oracle +// inserts incrementally as the EIP's pseudocode does, so agreement across the +// two algorithms is what rules out a shared misreading of the spec. + +func pbinBlake3Sum(b []byte) [32]byte { return blake3.Sum256(b) } + +var pbinBlake3Hash pbinHashFn = func(b []byte) common.Hash { return common.Hash(blake3.Sum256(b)) } + +// pbinOracleRootOf rebuilds the oracle trie from the whole key set, so a removed +// key is simply one the set no longer holds and nothing here depends on an +// incremental delete algorithm. +func pbinOracleRootOf(t *testing.T, entries map[string][]byte) [32]byte { + t.Helper() + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + + tree := &pbinOracleTree{} + for _, k := range keys { + tree.insert([]byte(k), entries[k]) + } + return pbinOracleMerkelizeWith(tree.root, pbinBlake3Sum) +} + +func TestPBinOracleMatchesSpecTrieRoots(t *testing.T) { + t.Parallel() + v := pbinLoadSpecVectors(t) + require.NotEmpty(t, v.Trie) + + for _, tc := range v.Trie { + t.Run(tc.Name, func(t *testing.T) { + entries := make(map[string][]byte, len(tc.Entries)) + for _, e := range tc.Entries { + key, err := hex.DecodeString(e.Key[2:]) + require.NoError(t, err) + val, err := hex.DecodeString(e.Value[2:]) + require.NoError(t, err) + entries[string(key)] = val + } + got := pbinOracleRootOf(t, entries) + require.Equal(t, tc.Root[2:], hex.EncodeToString(got[:])) + }) + } +} + +// Checks the root after every op in a reference sequence, not only at the end, +// so a divergence pins to the op that caused it. +func TestPBinOracleMatchesSpecSequenceRoots(t *testing.T) { + t.Parallel() + v := pbinLoadSpecVectors(t) + require.NotEmpty(t, v.Sequences) + + checked := 0 + for _, seq := range v.Sequences { + require.Len(t, seq.RootsAfter, len(seq.Ops)) + entries := make(map[string][]byte) + for i, op := range seq.Ops { + key, err := hex.DecodeString(op.Key[2:]) + require.NoError(t, err) + switch op.Op { + case "set": + val, err := hex.DecodeString(op.Value[2:]) + require.NoError(t, err) + entries[string(key)] = val + case "delete": + delete(entries, string(key)) + default: + t.Fatalf("unknown op %q", op.Op) + } + got := pbinOracleRootOf(t, entries) + require.Equal(t, seq.RootsAfter[i][2:], hex.EncodeToString(got[:]), + "seed %d diverges at op %d (%s)", seq.Seed, i, op.Op) + checked++ + } + } + t.Logf("replayed %d reference roots across %d sequences", checked, len(v.Sequences)) +} diff --git a/execution/commitment/pbin_specvectors_test.go b/execution/commitment/pbin_specvectors_test.go new file mode 100644 index 00000000000..f91f876e596 --- /dev/null +++ b/execution/commitment/pbin_specvectors_test.go @@ -0,0 +1,122 @@ +package commitment + +import ( + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +// Vectors exported from the EIP-8297 reference implementation in +// ethereum/execution-specs (branch projects/binary-trie), which hashes with +// BLAKE3. Comparisons against them either involve no hash (BASIC_DATA packing) +// or replay derivation under BLAKE3 through the injectable seam. +type pbinSpecVectors struct { + Meta map[string]string `json:"meta"` + BasicData []struct { + CodeSize uint64 `json:"code_size"` + Nonce uint64 `json:"nonce"` + Balance string `json:"balance"` + Value string `json:"value"` + } `json:"basic_data_vectors"` + Embedding struct { + Address string `json:"address"` + BasicDataKey string `json:"basic_data_key"` + CodeHashKey string `json:"code_hash_key"` + // slot reaches 2**255, so it must not go through float64 + Slots []struct { + Slot json.Number `json:"slot"` + Key string `json:"key"` + } `json:"slots"` + } `json:"embedding_vectors"` + Chunkify []pbinSpecChunkifyVector `json:"chunkify_vectors"` + Trie []pbinSpecTrieVector `json:"trie_vectors"` + Sequences []struct { + Seed int `json:"seed"` + Ops []struct { + Op string `json:"op"` + Key string `json:"key"` + Value string `json:"value"` + } `json:"ops"` + RootsAfter []string `json:"roots_after"` + } `json:"sequence_vectors"` +} + +type pbinSpecChunkifyVector struct { + Name string `json:"name"` + Code string `json:"code"` + Chunks []string `json:"chunks"` +} + +type pbinSpecTrieVector struct { + Name string `json:"name"` + Entries []struct { + Key string `json:"key"` + Value string `json:"value"` + } `json:"entries"` + Root string `json:"root"` +} + +func pbinLoadSpecVectors(t *testing.T) pbinSpecVectors { + t.Helper() + raw, err := os.ReadFile("testdata/eip8297_vectors.json") + require.NoError(t, err) + var v pbinSpecVectors + require.NoError(t, json.Unmarshal(raw, &v)) + require.Equal(t, "blake3", v.Meta["hasher"], "vectors require their generation hash") + return v +} + +func pbinMustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s[2:]) + require.NoError(t, err) + return b +} + +// BASIC_DATA packing is pure byte layout, so it compares against the reference +// directly despite the differing hash. +func TestPBinSpecBasicDataVectors(t *testing.T) { + t.Parallel() + v := pbinLoadSpecVectors(t) + require.NotEmpty(t, v.BasicData) + + for _, tc := range v.BasicData { + bal, err := uint256.FromDecimal(tc.Balance) + require.NoError(t, err) + + got, err := pbinEncodeBasicData(tc.Nonce, bal, tc.CodeSize) + require.NoError(t, err) + require.Equal(t, pbinMustHex(t, tc.Value), got[:], + "BASIC_DATA mismatch for code_size=%d nonce=%d balance=%s", tc.CodeSize, tc.Nonce, tc.Balance) + } +} + +// Compares full tree keys — zone, digest body and sub-index — against the +// reference under BLAKE3. Going through the production key hasher is what +// catches a derivation step that hashes outside the seam: a hardcoded Keccak +// site would diverge here. +func TestPBinSpecKeyRouting(t *testing.T) { + t.Parallel() + v := pbinLoadSpecVectors(t) + addr := pbinMustHex(t, v.Embedding.Address) + require.Len(t, addr, 20) + + hasher := pbinKeyHasherWith(pbinBlake3Hash) + require.Equal(t, pbinMustHex(t, v.Embedding.BasicDataKey), hasher(addr), "BASIC_DATA key") + + c := pbinDigestCache{sum: pbinBlake3Hash} + require.Equal(t, pbinMustHex(t, v.Embedding.CodeHashKey), c.accountKey(addr, pbinCodeHashLeafKey), "CODE_HASH key") + + for _, s := range v.Embedding.Slots { + slot, err := uint256.FromDecimal(s.Slot.String()) + require.NoError(t, err, "slot %s", s.Slot) + slotBytes := slot.Bytes32() + + plainKey := append(append(make([]byte, 0, len(addr)+len(slotBytes)), addr...), slotBytes[:]...) + require.Equal(t, pbinMustHex(t, s.Key), hasher(plainKey), "slot %s key", s.Slot) + } +} diff --git a/execution/commitment/pbin_state.go b/execution/commitment/pbin_state.go new file mode 100644 index 00000000000..78e808f2c75 --- /dev/null +++ b/execution/commitment/pbin_state.go @@ -0,0 +1,106 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// The pbin state blob is the root cell plus the three root flags — nothing per +// row. State is only encoded with every row folded, and unfold fully initializes +// a row before anything reads it, so the grid arrays restore as zero. +const ( + // pbinStateMarker opens every pbin blob. A hex blob opens with a root-flags + // byte ≤ 0x07, so the marker also refuses a cross-variant restore outright. + pbinStateMarker = 0xB1 + + pbinStateRootPresent = 1 + pbinStateRootChecked = 2 + pbinStateRootTouched = 4 + + pbinStateFlagsAll = pbinStateRootPresent | pbinStateRootChecked | pbinStateRootTouched +) + +var ( + errPBinStateBlob = errors.New("pbin: malformed state blob") + errPBinStateOpen = errors.New("pbin: trie state unavailable with rows open") + + _ StatefulTrie = (*PBinPatriciaHashed)(nil) +) + +func (pph *PBinPatriciaHashed) EncodeCurrentState(buf []byte) ([]byte, error) { + if pph.grid.activeRows != 0 || pph.currentKey.bitLen != 0 { + return nil, fmt.Errorf("%w: %d rows, %d-bit key", errPBinStateOpen, pph.grid.activeRows, pph.currentKey.bitLen) + } + var flags byte + if pph.rootPresent { + flags |= pbinStateRootPresent + } + if pph.rootChecked { + flags |= pbinStateRootChecked + } + if pph.rootTouched { + flags |= pbinStateRootTouched + } + buf = append(buf, pbinStateMarker, flags, 0, 0) + lenAt := len(buf) - 2 + if pph.grid.root.kind != pbinNodeEmpty { + var err error + if buf, err = pbinAppendCell(buf, &pph.grid.root); err != nil { + return nil, err + } + } + binary.BigEndian.PutUint16(buf[lenAt:], uint16(len(buf)-lenAt-2)) + return buf, nil +} + +// SetState is the inverse of EncodeCurrentState; an empty blob resets the engine. +func (pph *PBinPatriciaHashed) SetState(buf []byte) error { + if pph.grid.activeRows != 0 { + return fmt.Errorf("%w: cannot restore over %d rows", errPBinStateOpen, pph.grid.activeRows) + } + pph.Reset() + if len(buf) == 0 { + return nil + } + if len(buf) < 4 || buf[0] != pbinStateMarker { + return fmt.Errorf("%w: not a pbin blob", errPBinStateBlob) + } + flags := buf[1] + if flags&^byte(pbinStateFlagsAll) != 0 { + return fmt.Errorf("%w: unknown flags %08b", errPBinStateBlob, flags) + } + if rootLen := int(binary.BigEndian.Uint16(buf[2:4])); len(buf) != 4+rootLen { + return fmt.Errorf("%w: root cell of %d bytes in a %d-byte blob", errPBinStateBlob, rootLen, len(buf)) + } + if len(buf) > 4 { + pos, err := pbinDecodeCell(buf, 4, &pph.grid.root) + if err == nil && pos != len(buf) { + err = fmt.Errorf("%w: %d trailing bytes after the root cell", errPBinStateBlob, len(buf)-pos) + } + if err != nil { + pph.grid.root.reset() + return err + } + } + pph.rootPresent = flags&pbinStateRootPresent != 0 + pph.rootChecked = flags&pbinStateRootChecked != 0 + pph.rootTouched = flags&pbinStateRootTouched != 0 + return nil +} diff --git a/execution/commitment/pbin_state_test.go b/execution/commitment/pbin_state_test.go new file mode 100644 index 00000000000..ab7a83f3987 --- /dev/null +++ b/execution/commitment/pbin_state_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/length" +) + +// Two same-group storage slots share the first 520 bits of their tree keys, so +// the tree's one branch sits deeper than any depth a single byte can hold. The +// encoded state has to carry that depth across a restart. +func TestPBinRestartRoundTripDeepPath(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(51) + stored := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + rootBefore := pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + require.Equal(t, pbinNodeBranch, pph.grid.root.kind) + require.Greater(t, int(pph.grid.root.prefix.bitLen), 256, "the corpus must put the branch past byte-depth range") + + blob, err := pph.EncodeCurrentState(nil) + require.NoError(t, err) + + restored := NewPBinPatriciaHashed(ms) + require.NoError(t, restored.SetState(blob)) + rootAfter, err := restored.RootHash() + require.NoError(t, err) + require.Equal(t, rootBefore, rootAfter, "restored engine must reproduce the pre-restart root") + + more := new(pbinTestCorpus).storage(addr, pbinOracleSlot(258), 0x03) + require.NoError(t, ms.applyPlainUpdates(more.plainKeys, more.updates)) + rootContinued := pbinTestProcess(t, restored, more.plainKeys, more.updates) + + full := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02). + storage(addr, pbinOracleSlot(258), 0x03) + require.Equal(t, full.oracleRoot(t), rootContinued, "the restored engine must keep folding correctly") +} + +// The three root flags are the only engine state beside the root cell, so losing +// one to the blob changes how the next run treats the stored tree. +func TestPBinStateBlobRoundTripsFlags(t *testing.T) { + t.Parallel() + + ms, storedRoot := pbinTestStoredTree(t) + pph := NewPBinPatriciaHashed(ms) + require.NoError(t, pph.loadRoot()) + + blob, err := pph.EncodeCurrentState(nil) + require.NoError(t, err) + + restored := NewPBinPatriciaHashed(ms) + require.NoError(t, restored.SetState(blob)) + require.Equal(t, pph.rootChecked, restored.rootChecked) + require.Equal(t, pph.rootTouched, restored.rootTouched) + require.Equal(t, pph.rootPresent, restored.rootPresent) + + root, err := restored.RootHash() + require.NoError(t, err) + require.Equal(t, storedRoot, root) +} + +// Following the hex convention, no state blob resets the engine; the tree is +// then found again through the stored root record rather than lost. +func TestPBinSetStateEmptyResetsToStored(t *testing.T) { + t.Parallel() + + ms, storedRoot := pbinTestStoredTree(t) + pph := NewPBinPatriciaHashed(ms) + require.NoError(t, pph.SetState(nil)) + require.False(t, pph.rootChecked) + + root, err := pph.RootHash() + require.NoError(t, err) + require.Equal(t, storedRoot, root) +} + +// The blob is read back by whatever engine the datadir opens with, so a pbin +// engine handed a hex blob (or a damaged pbin one) must refuse it instead of +// decoding garbage into the root cell. +func TestPBinSetStateRejectsForeignBlob(t *testing.T) { + t.Parallel() + + hexBlob, err := NewHexPatriciaHashed(length.Addr, nil, DefaultTrieConfig()).EncodeCurrentState(nil) + require.NoError(t, err) + + pph, ms := pbinTestEngine(t) + validBlob, err := pph.EncodeCurrentState(nil) + require.NoError(t, err) + + for name, blob := range map[string][]byte{ + "hex state blob": hexBlob, + "truncated": validBlob[:len(validBlob)-1], + "trailing bytes": append(append([]byte{}, validBlob...), 0x00), + "marker only": {validBlob[0]}, + "unknown flags": {validBlob[0], 0xF8, 0, 0}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + fresh := NewPBinPatriciaHashed(ms) + require.ErrorIs(t, fresh.SetState(blob), errPBinStateBlob, "blob %x must be refused", blob) + }) + } +} + +// With a row still open, part of the tree lives in the grid arrays and a +// root-cell snapshot would silently drop it. +func TestPBinStateRefusesOpenRows(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + pph.grid.activeRows = 1 + + _, err := pph.EncodeCurrentState(nil) + require.ErrorIs(t, err, errPBinStateOpen) + require.ErrorIs(t, pph.SetState(nil), errPBinStateOpen) +} diff --git a/execution/commitment/pbin_storage_layout_test.go b/execution/commitment/pbin_storage_layout_test.go new file mode 100644 index 00000000000..1948433b1f4 --- /dev/null +++ b/execution/commitment/pbin_storage_layout_test.go @@ -0,0 +1,178 @@ +package commitment + +// Measures what EIP-8297's storage layout costs and what its co-location buys. +// +// The embedding splits storage three ways: slots 0..63 sit in the account header +// under 34-byte keys sharing the account's stem; slots from 64 on go to the +// storage zone under 66-byte keys carrying BOTH the account stem digest and a +// per-256-slot group digest; and slots inside one group share that stem, which is +// the co-location the design is built around. +// +// Each pattern below writes the same NUMBER of slots to the same account, so the +// only thing that varies is where the embedding puts them. + +import ( + "context" + "encoding/binary" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// pbinSlotAt returns a 32-byte big-endian slot number. +func pbinSlotAt(n uint64) []byte { + var s [32]byte + binary.BigEndian.PutUint64(s[24:], n) + return s[:] +} + +func TestPBinStorageLayoutCost(t *testing.T) { + t.Parallel() + + const slots = 16 + addr := pbinOracleAddr(3) + + patterns := []struct { + name string + slot func(i int) uint64 + }{ + // 0..15: account header, 34-byte keys, one shared stem. + {"header slots 0..15", func(i int) uint64 { return uint64(i) }}, + // 64..79: storage zone, one group (64/256 == 79/256 == 0), shared group stem. + {"one group, adjacent 64..79", func(i int) uint64 { return 64 + uint64(i) }}, + // 256 apart: every slot lands in its own group, so no group stem is shared. + {"one slot per group, 256 apart", func(i int) uint64 { return 256 * uint64(i+1) }}, + // Far apart: distinct groups and distinct high bits, the worst case for sharing. + {"scattered across the zone", func(i int) uint64 { return 1 << (uint(i) + 20) }}, + } + + type row struct { + name string + nodes, total int + leafKey, branch int + leaves, branches int + } + rows := make([]row, 0, len(patterns)) + + buildRow := func(p struct { + name string + slot func(i int) uint64 + }) row { + c := new(pbinTestCorpus).account(addr, 1, 100, pbinTestCodeHash(0)) + for i := range slots { + c = c.storage(addr, pbinSlotAt(p.slot(i)), byte(i+1)) + } + + rec := &pbinWitnessRecorder{} + _, pph := pbinWitnessProcess(t, c, rec) + defer pph.Release() + + r := row{name: p.name} + for _, n := range rec.byHash(t) { + r.total += len(n) + r.nodes++ + switch n[0] { + case pbinLeafTag: + r.leaves++ + r.leafKey += len(n) - 1 - pbinValueLength + case pbinBranchTag: + r.branches++ + r.branch += len(n) + default: + t.Fatalf("unknown tag %#x", n[0]) + } + } + return r + } + for _, p := range patterns { + rows = append(rows, buildRow(p)) + } + + t.Logf("%d storage slots on one account, by where the embedding puts them:", slots) + t.Logf("%-32s %6s %8s %8s %8s %7s %9s", "pattern", "nodes", "bytes", "leafkey", "branch", "leaves", "key/leaf") + for _, r := range rows { + t.Logf("%-32s %6d %8d %8d %8d %7d %9.1f", + r.name, r.nodes, r.total, r.leafKey, r.branch, r.leaves, + float64(r.leafKey)/float64(max(r.leaves, 1))) + } + + base := rows[0].total + for _, r := range rows[1:] { + t.Logf(" %-30s %.2fx the header-slot case", r.name, float64(r.total)/float64(base)) + } + + // The header window is 64 slots wide, so slot 63 is a 34-byte key and slot 64 + // is a 66-byte one — the embedding's sharpest discontinuity. + require.Less(t, rows[0].leafKey, rows[1].leafKey, + "header slots must carry less key material than storage-zone slots") +} + +// TestPBinStorageGroupSharing isolates co-location: the same 16 slots, once packed +// into one group and once spread one-per-group. Both are storage-zone keys of the +// same width, so any difference is the shared group stem alone. +// +// Two things to get right: +// +// - Measure the PRUNED witness. Witnesses returns a superset that callers prune +// with PBinWitnessNodesForKeys; the superset carries off-path siblings re-hashed +// during the fold, and counting those reverses the sign of the result. +// - Vary the right axis. Other accounts' storage diverges above this account's +// stem and cancels in the difference, so filler on other accounts cannot move +// the number. What matters is how many OTHER groups this account already holds. +func TestPBinStorageGroupSharing(t *testing.T) { + t.Parallel() + + const slots = 16 + addr := pbinOracleAddr(5) + + // Two passes. The first builds the whole tree so the branch records exist; the + // second proves ONLY the 16 slots. Measuring the first pass would include every + // filler account and hide exactly the effect under test. + measure := func(filler int, step uint64) (nodes, total int) { + // Filler is untouched storage on the SAME account: that is the axis the + // co-location property is about. Other accounts' keys diverge above this + // account's stem and cancel between the two arms. + full := new(pbinTestCorpus).account(addr, 1, 100, pbinTestCodeHash(0)) + for i := range filler { + full = full.storage(addr, pbinSlotAt(1<<20+256*uint64(i)), 0x7f) + } + touched := new(pbinTestCorpus) + for i := range slots { + full = full.storage(addr, pbinSlotAt(64+step*uint64(i)), byte(i+1)) + touched = touched.storage(addr, pbinSlotAt(64+step*uint64(i)), byte(i+1)) + } + + pph, ms := pbinTestEngine(t) + defer pph.Release() + full.applyTo(t, ms) + pbinTestProcess(t, pph, full.plainKeys, full.updates) + + pph.Reset() + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), touched.plainKeys, touched.updates) + got, proved, root, err := pph.Witnesses(context.Background(), upd, false, "") + require.NoError(t, err) + lean, err := PBinWitnessNodesForKeys(got, root, proved) + require.NoError(t, err) + for _, n := range lean { + total += len(n) + nodes++ + } + return nodes, total + } + + t.Logf("%8s %10s %10s %12s %10s", "other grps", "adjacent", "per-group", "co-loc saves", "of total") + for _, filler := range []int{0, 16, 64, 256, 1024, 4096} { + adjN, adjB := measure(filler, 1) + sepN, sepB := measure(filler, 256) + t.Logf("%8d %6d/%4dB %6d/%4dB %10dB %9.1f%%", + filler, adjN, adjB, sepN, sepB, sepB-adjB, 100*float64(sepB-adjB)/float64(sepB)) + } +} + +func pbinTestCodeHash(n byte) (h [32]byte) { + h[31] = n + return h +} + +var _ = fmt.Sprintf diff --git a/execution/commitment/pbin_unfold_test.go b/execution/commitment/pbin_unfold_test.go new file mode 100644 index 00000000000..5035c33bcb2 --- /dev/null +++ b/execution/commitment/pbin_unfold_test.go @@ -0,0 +1,388 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +func pbinTestEngine(t *testing.T) (*PBinPatriciaHashed, *MockState) { + t.Helper() + ms := NewMockState(t) + return NewPBinPatriciaHashed(ms), ms +} + +// pbinTestSpecCell spells a cell prefix out bit by bit, so a test can name a +// divergence point instead of deriving one. +func pbinTestSpecCell(t *testing.T, kind pbinNodeKind, spec string) pbinCell { + t.Helper() + c := pbinTestEmptyCell() + c.kind = kind + c.prefix = pbinTestPathFromBits(t, pbinTestBitSpec(t, spec)) + switch kind { + case pbinNodeLeaf: + // A stored leaf always names a plain key; a record without one is rejected. + c.storageAddrLen = length.Addr + length.Hash + c.storageAddr[0], c.storageAddr[1] = 0xB1, byte(len(spec)) + case pbinNodeBranch: + c.hash = common.Hash{0xB1, byte(len(spec))} + c.hashLen = length.Hash + } + return c +} + +func pbinTestPutRecord(t *testing.T, ms *MockState, path pbinBitpath, cells [2]pbinCell) { + t.Helper() + var enc pbinBranchEncoder + rec, err := enc.encode(0b11, 0b11, &cells) + require.NoError(t, err) + require.NoError(t, ms.PutBranch(pbinEncodeBitPath(&path), bytes.Clone(rec), nil)) +} + +func pbinTestPutRootCell(t *testing.T, ms *MockState, c pbinCell) { + t.Helper() + rec, err := pbinAppendCell(nil, &c) + require.NoError(t, err) + require.NoError(t, ms.PutBranch(pbinRootKey, rec, nil)) +} + +// pbinTestPutTopRecord seeds a node record at the empty path plus the root cell +// naming it — the pair a stored tree always writes. +func pbinTestPutTopRecord(t *testing.T, ms *MockState, cells [2]pbinCell) { + t.Helper() + pbinTestPutRecord(t, ms, pbinBitpath{}, cells) + pbinTestPutRootCell(t, ms, pbinTestSpecCell(t, pbinNodeBranch, "")) +} + +func pbinTestUnfoldStep(t *testing.T, pph *PBinPatriciaHashed, probe *pbinBitpath) { + t.Helper() + u := pph.needUnfolding(probe) + if u.action == pbinUnfoldRoot { + require.NoError(t, pph.unfold(probe, u)) + u = pph.needUnfolding(probe) + } + require.NoError(t, pph.unfold(probe, u)) +} + +// "The probe agrees with the whole prefix" and "the probe leaves the prefix +// partway" must be different answers: only the second shortens a stored prefix, +// which is inside that node's hash. The hex engine's cpl+1 conflates them +// because it has a terminator nibble to hide behind. +func TestPBinNeedUnfolding(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + root pbinCell + rootChecked bool + probe string + want pbinUnfolding + }{ + { + name: "an unchecked empty root reads the root cell record", + root: pbinTestEmptyCell(), + probe: "1010", + want: pbinUnfolding{action: pbinUnfoldRoot}, + }, + { + name: "a checked empty root needs nothing", + root: pbinTestEmptyCell(), + rootChecked: true, + probe: "1010", + want: pbinUnfolding{}, + }, + { + name: "a branch with an empty prefix is a record read", + root: pbinTestSpecCell(t, pbinNodeBranch, ""), + probe: "1010", + want: pbinUnfolding{action: pbinUnfoldRecord}, + }, + { + name: "cpl == 0 splits at the first bit", + root: pbinTestSpecCell(t, pbinNodeBranch, "1011"), + probe: "0011", + want: pbinUnfolding{action: pbinUnfoldSplit, matched: 0}, + }, + { + name: "cpl < len(prefix) splits inside it", + root: pbinTestSpecCell(t, pbinNodeBranch, "1011"), + probe: "1001", + want: pbinUnfolding{action: pbinUnfoldSplit, matched: 2}, + }, + { + name: "cpl == len(prefix) descends through it", + root: pbinTestSpecCell(t, pbinNodeBranch, "1011"), + probe: "10110", + want: pbinUnfolding{action: pbinUnfoldDescend, matched: 4}, + }, + { + name: "a leaf the probe fully matches is already the target", + root: pbinTestSpecCell(t, pbinNodeLeaf, "1011"), + probe: "1011", + want: pbinUnfolding{}, + }, + { + name: "a leaf the probe leaves splits", + root: pbinTestSpecCell(t, pbinNodeLeaf, "1011"), + probe: "1000", + want: pbinUnfolding{action: pbinUnfoldSplit, matched: 2}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + pph.grid.root = tc.root + pph.rootChecked = tc.rootChecked + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, tc.probe)) + require.Equal(t, tc.want, pph.needUnfolding(&probe)) + }) + } +} + +func TestPBinNeedUnfoldingSelectsCellByBranchBit(t *testing.T) { + t.Parallel() + + pph, ms := pbinTestEngine(t) + pbinTestPutTopRecord(t, ms, [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "000"), + pbinTestSpecCell(t, pbinNodeBranch, "111"), + }) + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, "0000")) + pbinTestUnfoldStep(t, pph, &probe) + require.Equal(t, 1, pph.grid.activeRows) + require.Equal(t, int16(1), pph.grid.depths[0]) + + for _, tc := range []struct { + name string + probe string + want pbinUnfolding + }{ + {"left cell, leaf fully matched", "0000", pbinUnfolding{}}, + {"left cell, leaf left at its last bit", "0001", pbinUnfolding{action: pbinUnfoldSplit, matched: 2}}, + {"right cell, branch fully matched", "1111", pbinUnfolding{action: pbinUnfoldDescend, matched: 3}}, + {"right cell, branch left inside its prefix", "1101", pbinUnfolding{action: pbinUnfoldSplit, matched: 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, tc.probe)) + require.Equal(t, tc.want, pph.needUnfolding(&probe)) + }) + } +} + +// EIP-8297 admits a branch node with no prefix, so a zero-length prefix cannot +// double as "this cell is not a stored branch". The engine must read the record +// below and descend into it. +func TestPBinUnfoldEmptyPrefixBranchRecord(t *testing.T) { + t.Parallel() + + childCells := [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "0101"), + pbinTestSpecCell(t, pbinNodeLeaf, "1100"), + } + rootCells := [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "010"), + pbinTestSpecCell(t, pbinNodeBranch, ""), + } + childPath := pbinTestPathFromBits(t, pbinTestBitSpec(t, "1")) + + pph, ms := pbinTestEngine(t) + pbinTestPutTopRecord(t, ms, rootCells) + pbinTestPutRecord(t, ms, childPath, childCells) + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, "1101")) + pbinTestUnfoldStep(t, pph, &probe) + require.Equal(t, 1, pph.grid.activeRows) + + u := pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldRecord}, u, + "a branch cell with a zero-length prefix is still a stored node") + + require.NoError(t, pph.unfold(&probe, u)) + require.Equal(t, 2, pph.grid.activeRows) + require.Equal(t, int16(2), pph.grid.depths[1]) + require.Equal(t, childPath, pph.currentKey) + require.True(t, pph.grid.branchBefore[1]) + require.Equal(t, childCells, pph.grid.rows[1]) + require.Equal(t, uint16(0b11), pph.grid.afterMap[1]) + require.Equal(t, uint16(0), pph.grid.touchMap[1]) +} + +// A missing record below such a cell is an inconsistency, not an empty subtree. +func TestPBinUnfoldEmptyPrefixBranchRecordMissing(t *testing.T) { + t.Parallel() + + pph, ms := pbinTestEngine(t) + pbinTestPutTopRecord(t, ms, [2]pbinCell{ + pbinTestSpecCell(t, pbinNodeLeaf, "010"), + pbinTestSpecCell(t, pbinNodeBranch, ""), + }) + + probe := pbinTestPathFromBits(t, pbinTestBitSpec(t, "1101")) + pbinTestUnfoldStep(t, pph, &probe) + require.ErrorIs(t, pph.unfold(&probe, pph.needUnfolding(&probe)), errPBinMissingBranch) +} + +func TestPBinUnfoldEmptyRoot(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + probe := pbinPathFromBytes(pbinTreeKeyAccount(pbinOracleAddr(1), pbinBasicDataLeafKey)) + + u := pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldRoot}, u) + require.NoError(t, pph.unfold(&probe, u)) + + require.Equal(t, 0, pph.grid.activeRows) + require.True(t, pph.rootChecked) + require.Equal(t, pbinUnfolding{}, pph.needUnfolding(&probe), "a checked empty root does not unfold again") +} + +// The divergence bit walks both word boundaries of the [9]uint64 path. A split +// moves the node below one level down and re-cuts its prefix, dropping the bit +// the new row branches on (eip:"Insertion and deletion"). +func TestPBinUnfoldSplitsInsidePrefix(t *testing.T) { + t.Parallel() + + full := pbinTestPathFromBits(t, pbinTestBitPattern(pbinMaxPathBits)) + + for _, divergence := range []int16{0, 63, 64, 65, 271, 527} { + t.Run(fmt.Sprintf("bit %d", divergence), func(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + pph.grid.root = pbinTestEmptyCell() + pph.grid.root.kind = pbinNodeLeaf + pph.grid.root.prefix = full + pph.rootPresent = true + + probe := full + probe.setBitAt(divergence, full.bit(divergence)^1) + + u := pph.needUnfolding(&probe) + require.Equal(t, pbinUnfolding{action: pbinUnfoldSplit, matched: divergence}, u) + require.NoError(t, pph.unfold(&probe, u)) + + require.Equal(t, 1, pph.grid.activeRows) + require.Equal(t, divergence+1, pph.grid.depths[0]) + require.Equal(t, full.slice(0, divergence), pph.currentKey) + + survivorBit := full.bit(divergence) + survivor := &pph.grid.rows[0][survivorBit] + require.Equal(t, pbinNodeLeaf, survivor.kind) + require.Equal(t, full.slice(divergence+1, full.bitLen), survivor.prefix, + "the survivor drops the bit the new row branches on") + require.Equal(t, pbinNodeEmpty, pph.grid.rows[0][1-survivorBit].kind, + "the probe's own side is left for updateCell to fill") + + require.Equal(t, uint16(0), pph.grid.touchMap[0]) + require.Equal(t, uint16(1)<. + +package commitment + +import ( + "bytes" + "context" + "fmt" + "slices" + + keccak "github.com/erigontech/fastkeccak" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +type pbinUpdateSink func(treeKey, plainKey []byte, update *Update) error + +type pbinUpdateStream struct { + state PatriciaContext + emit pbinUpdateSink + + siblingKey [pbinAccountKeyLength]byte + codeChunks []pbinCodeChunk + keyDigest pbinDigestCache + + // witness is what the parent state cannot tell a witness pass about the block. + // See chunkSource and removesAccount. + witness PBinWitnessBlock + witnessPass bool + + // pendingRemoval holds storage-subtree prefixes waiting for the walk to reach + // their zone. Removals are queued in account order, which is prefix order. + pendingRemoval [][]byte +} + +type pbinCodeChunk struct { + key [pbinCodeKeyLength]byte + value [pbinValueLength]byte +} + +type pbinCodeContext interface { + Code(plainKey []byte) ([]byte, error) +} + +func (s *pbinUpdateStream) process(ctx context.Context, updates *Updates, state PatriciaContext, emit pbinUpdateSink) (uint64, error) { + s.reset() + s.state, s.emit = state, emit + defer func() { s.state, s.emit = nil, nil }() + + var processed uint64 + err := updates.HashSort(ctx, nil, func(treeKey, plainKey []byte, stateUpdate *Update) error { + if err := s.processKey(treeKey, plainKey, stateUpdate); err != nil { + return err + } + processed++ + return nil + }) + if err != nil { + return processed, err + } + if err := s.flushCodeChunks(); err != nil { + return processed, err + } + if err := s.flushRemovals(nil); err != nil { + return processed, err + } + return processed, nil +} + +func (s *pbinUpdateStream) reset() { + s.state, s.emit = nil, nil + s.codeChunks = s.codeChunks[:0] + s.pendingRemoval = s.pendingRemoval[:0] +} + +func (s *pbinUpdateStream) release() { + s.reset() + s.keyDigest = pbinDigestCache{} + s.witness = PBinWitnessBlock{} +} + +// processKey expands an account into its header leaves. Code chunks are delayed +// until emitting them cannot move the ordered trie walk back. +func (s *pbinUpdateStream) processKey(treeKey, plainKey []byte, stateUpdate *Update) error { + if err := s.flushCodeChunksBefore(treeKey); err != nil { + return err + } + if err := s.flushRemovalsBefore(treeKey); err != nil { + return err + } + update := stateUpdate + if update == nil { + var err error + if update, err = s.stateOf(plainKey); err != nil { + return err + } + } + if len(plainKey) == length.Addr && s.removesAccount(plainKey, update) { + if err := s.removeAccount(plainKey); err != nil { + return err + } + } + if err := s.emit(treeKey, plainKey, update); err != nil { + return err + } + if len(plainKey) != length.Addr { + return nil + } + return s.emitCodeLeaves(treeKey, plainKey, update) +} + +// emitCodeLeaves writes the header sibling the account's code selects — +// CODE_HASH, or DELEGATION for an EIP-7702 indicator — and removes the other. +// The stream is told nothing about what the account held before, so both +// removals are unconditional. The indicator is no account field, so its leaf +// carries the value itself and no plain key. +func (s *pbinUpdateStream) emitCodeLeaves(basicDataKey, plainKey []byte, update *Update) error { + code, codeHash, err := s.chunkSource(plainKey, update) + if err != nil { + return err + } + if pbinIsDelegation(code) { + if err := s.emitSibling(basicDataKey, pbinCodeHashLeafKey, plainKey, &Update{Flags: DeleteUpdate}); err != nil { + return err + } + indicator := Update{Flags: StorageUpdate, StorageLen: pbinValueLength, Storage: pbinEncodeDelegation(code)} + return s.emitSibling(basicDataKey, pbinDelegationLeafKey, nil, &indicator) + } + if err := s.emitSibling(basicDataKey, pbinCodeHashLeafKey, plainKey, update); err != nil { + return err + } + if err := s.emitSibling(basicDataKey, pbinDelegationLeafKey, plainKey, &Update{Flags: DeleteUpdate}); err != nil { + return err + } + s.queueChunks(code, codeHash) + return nil +} + +// removesAccount reports whether the block removes this account. A witness pass +// reads the parent state, where an account the block creates is absent too, so +// there it has to be told rather than infer it. +func (s *pbinUpdateStream) removesAccount(plainKey []byte, update *Update) bool { + if s.witnessPass { + _, removed := s.witness.Removed[string(plainKey)] + return removed + } + return update.Deleted() +} + +// removeAccount drops the two subtrees an account owns — its header stem, and +// its storage prefix once the walk reaches that zone — rather than the leaves it +// holds, which for storage nothing enumerates. Code chunks always stay, which +// parts from the reference suite when the removed account was the sole holder; +// EIP-6780 bounds that to states the chain cannot reach, since an account +// deleted with its code was created in the same transaction and a +// create-and-destroy merges to a bare deletion that inserts no chunk +// (eip:"Zero values and deletion"). +func (s *pbinUpdateStream) removeAccount(plainKey []byte) error { + drop := Update{Flags: DeleteUpdate} + if err := s.emit(s.keyDigest.accountHeaderStem(plainKey), plainKey, &drop); err != nil { + return err + } + s.pendingRemoval = append(s.pendingRemoval, s.keyDigest.accountStoragePrefix(plainKey)) + return nil +} + +func (s *pbinUpdateStream) flushRemovalsBefore(treeKey []byte) error { + if len(s.pendingRemoval) == 0 || treeKey[0] < pbinStorageZone { + return nil + } + return s.flushRemovals(treeKey) +} + +// flushRemovals emits the queued storage-prefix drops that sort before upTo, or +// all of them when upTo is nil. A drop has to land before any storage key it +// covers; a queue out of order would fail the engine's ascending-visit check +// rather than pass silently. +func (s *pbinUpdateStream) flushRemovals(upTo []byte) error { + drop := Update{Flags: DeleteUpdate} + sent := 0 + for _, prefix := range s.pendingRemoval { + if upTo != nil && bytes.Compare(prefix, upTo) >= 0 { + break + } + if err := s.emit(prefix, nil, &drop); err != nil { + return err + } + sent++ + } + s.pendingRemoval = append(s.pendingRemoval[:0], s.pendingRemoval[sent:]...) + return nil +} + +// chunkSource is the code an account's chunk keys derive from, with the hash +// addressing its chunks. A witness pass walks the parent state, where a +// contract the block creates has no code, so it needs the override to reach the +// same keys the fold did. Only key derivation moves; values stay pre-state. +// A deletion's code fields are whatever the batch merge left behind, not +// state, so a removed account is codeless here. +func (s *pbinUpdateStream) chunkSource(plainKey []byte, update *Update) ([]byte, common.Hash, error) { + if code, ok := s.witness.Code[string(plainKey)]; ok && s.witnessPass { + return code, common.Hash(keccak.Sum256(code)), nil + } + if update.Deleted() || update.CodeSize == 0 { + return nil, common.Hash{}, nil + } + code, err := s.codeOf(plainKey) + if err != nil { + return nil, common.Hash{}, err + } + if uint64(len(code)) != update.CodeSize { + return nil, common.Hash{}, fmt.Errorf("pbin: account %x says %d code bytes, the code domain holds %d", + plainKey, update.CodeSize, len(code)) + } + return code, update.CodeHash, nil +} + +func (s *pbinUpdateStream) queueChunks(code []byte, codeHash common.Hash) { + for i, chunk := range pbinChunkifyCode(code) { + var cc pbinCodeChunk + copy(cc.key[:], s.keyDigest.codeChunkKey(codeHash, i)) + cc.value = chunk + s.codeChunks = append(s.codeChunks, cc) + } +} + +func (s *pbinUpdateStream) flushCodeChunksBefore(treeKey []byte) error { + if len(s.codeChunks) == 0 || treeKey[0] <= pbinCodeZone { + return nil + } + return s.flushCodeChunks() +} + +func (s *pbinUpdateStream) flushCodeChunks() error { + if len(s.codeChunks) == 0 { + return nil + } + slices.SortFunc(s.codeChunks, func(a, b pbinCodeChunk) int { return bytes.Compare(a.key[:], b.key[:]) }) + + var prev *pbinCodeChunk + for i := range s.codeChunks { + cc := &s.codeChunks[i] + if prev != nil && cc.key == prev.key { + if cc.value != prev.value { + return fmt.Errorf("pbin: code chunk %x carries two values", cc.key[:]) + } + continue + } + update := Update{Flags: StorageUpdate, StorageLen: pbinValueLength, Storage: cc.value} + if err := s.emit(cc.key[:], nil, &update); err != nil { + return err + } + prev = cc + } + s.codeChunks = s.codeChunks[:0] + return nil +} + +func (s *pbinUpdateStream) codeOf(plainKey []byte) ([]byte, error) { + ctx, ok := s.state.(pbinCodeContext) + if !ok { + return nil, fmt.Errorf("%w: %T serves no code, needed to chunk account %x", + ErrPBinUnsupported, s.state, plainKey) + } + code, err := ctx.Code(plainKey) + if err != nil { + return nil, fmt.Errorf("pbin: read code %x: %w", plainKey, err) + } + return code, nil +} + +func (s *pbinUpdateStream) stateOf(plainKey []byte) (*Update, error) { + if len(plainKey) == length.Addr { + update, err := s.state.Account(plainKey) + if err != nil { + return nil, fmt.Errorf("pbin: read account %x: %w", plainKey, err) + } + return update, nil + } + update, err := s.state.Storage(plainKey) + if err != nil { + return nil, fmt.Errorf("pbin: read storage %x: %w", plainKey, err) + } + return update, nil +} + +func (s *pbinUpdateStream) emitSibling(basicDataKey []byte, subIndex byte, plainKey []byte, update *Update) error { + if len(basicDataKey) != pbinAccountKeyLength || basicDataKey[pbinAccountKeyLength-1] != pbinBasicDataLeafKey { + return fmt.Errorf("pbin: %x is not a BASIC_DATA key", basicDataKey) + } + copy(s.siblingKey[:], basicDataKey) + s.siblingKey[pbinAccountKeyLength-1] = subIndex + return s.emit(s.siblingKey[:], plainKey, update) +} diff --git a/execution/commitment/pbin_values.go b/execution/commitment/pbin_values.go new file mode 100644 index 00000000000..87906a7947d --- /dev/null +++ b/execution/commitment/pbin_values.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "errors" + "fmt" + + "github.com/holiman/uint256" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/common/length" +) + +// pbinValueLength is the one leaf value size EIP-8297 admits (eip:"Tree structure"). +const pbinValueLength = 32 + +// BASIC_DATA field offsets within the leaf value (eip:"Header values"). Byte 0 (version) +// and the reserved bytes 1..3 stay zero. +const ( + pbinBasicDataCodeSizeOffset = 4 + pbinBasicDataNonceOffset = 8 + pbinBasicDataBalanceOffset = 16 +) + +var ( + errPBinBalanceOverflow = errors.New("pbin: balance does not fit the 16-byte BASIC_DATA field") + errPBinCodeSizeOverflow = errors.New("pbin: code size does not fit the 4-byte BASIC_DATA field") +) + +// pbinEncodeBasicData packs code_size, nonce and balance big-endian into the +// BASIC_DATA leaf value. A value the field cannot hold is an error rather than a +// silent truncation, which would commit a wrong root. +func pbinEncodeBasicData(nonce uint64, balance *uint256.Int, codeSize uint64) ([pbinValueLength]byte, error) { + var v [pbinValueLength]byte + if balance.BitLen() > 128 { + return v, fmt.Errorf("%w: %s", errPBinBalanceOverflow, balance) + } + if codeSize > 1<<32-1 { + return v, fmt.Errorf("%w: %d", errPBinCodeSizeOverflow, codeSize) + } + binary.BigEndian.PutUint32(v[pbinBasicDataCodeSizeOffset:], uint32(codeSize)) + binary.BigEndian.PutUint64(v[pbinBasicDataNonceOffset:], nonce) + b32 := balance.Bytes32() + copy(v[pbinBasicDataBalanceOffset:], b32[16:]) + return v, nil +} + +// pbinCodeHashValue returns the CODE_HASH leaf value, mapping an unset hash to +// the empty-bytecode hash as the spec requires for a codeless account +// (eip:"Header values"). +func pbinCodeHashValue(codeHash common.Hash) [pbinValueLength]byte { + if codeHash == (common.Hash{}) { + return empty.CodeHash + } + return codeHash +} + +// EIP-7702 delegation indicators (eip:"Delegation"). Classification reads the +// code bytes, never the hash — a code hash may begin with the marker too. +var pbinDelegationMarker = [3]byte{0xEF, 0x01, 0x00} + +const pbinDelegationCodeLength = 23 + +func pbinIsDelegation(code []byte) bool { + return len(code) == pbinDelegationCodeLength && [3]byte(code) == pbinDelegationMarker +} + +// pbinEncodeDelegation right-pads the indicator into the DELEGATION leaf value. +// This is not the chunk encoding: an indicator never executes, so byte 0 holds +// code rather than a PUSHDATA count. +func pbinEncodeDelegation(code []byte) [pbinValueLength]byte { + if len(code) != pbinDelegationCodeLength { + panic(fmt.Sprintf("pbin: delegation indicator of %d bytes, want %d", len(code), pbinDelegationCodeLength)) + } + var v [pbinValueLength]byte + copy(v[:], code) + return v +} + +func pbinEncodeStorageValue(value []byte) [pbinValueLength]byte { + if len(value) > length.Hash { + panic(fmt.Sprintf("pbin: storage value of %d bytes exceeds %d", len(value), length.Hash)) + } + var v [pbinValueLength]byte + copy(v[pbinValueLength-len(value):], value) + return v +} diff --git a/execution/commitment/pbin_values_test.go b/execution/commitment/pbin_values_test.go new file mode 100644 index 00000000000..913f5eb1be9 --- /dev/null +++ b/execution/commitment/pbin_values_test.go @@ -0,0 +1,200 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/hex" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// The expectations are hand-written hex, never the encoder's own output: the +// oracle consumes this same encoder, so a differential root test would not see +// a value-encoding bug. +func TestPBinEncodeBasicData(t *testing.T) { + t.Parallel() + + maxU128 := new(uint256.Int).Sub(new(uint256.Int).Lsh(uint256.NewInt(1), 128), uint256.NewInt(1)) + + for _, tc := range []struct { + name string + codeSize uint64 + nonce uint64 + balance *uint256.Int + want string + }{ + { + name: "empty account", + balance: uint256.NewInt(0), + want: "0000000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "distinct bytes in every field", + codeSize: 0xDEADBEEF, + nonce: 0x0102030405060708, + balance: new(uint256.Int).SetBytes(common.FromHex("0x0102030405060708090a0b0c0d0e0f10")), + want: "00000000deadbeef01020304050607080102030405060708090a0b0c0d0e0f10", + }, + { + name: "every field at its maximum", + codeSize: 0xFFFFFFFF, + nonce: 0xFFFFFFFFFFFFFFFF, + balance: maxU128, + want: "00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + { + name: "code_size occupies offsets 4..7 only", + codeSize: 1, + balance: uint256.NewInt(0), + want: "0000000000000001000000000000000000000000000000000000000000000000", + }, + { + name: "nonce occupies offsets 8..15 only", + nonce: 1, + balance: uint256.NewInt(0), + want: "0000000000000000000000000000000100000000000000000000000000000000", + }, + { + name: "balance occupies offsets 16..31 only", + balance: uint256.NewInt(1), + want: "0000000000000000000000000000000000000000000000000000000000000001", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := pbinEncodeBasicData(tc.nonce, tc.balance, tc.codeSize) + require.NoError(t, err) + require.Equal(t, tc.want, hex.EncodeToString(got[:])) + require.Len(t, got, pbinValueLength) + }) + } +} + +func TestPBinEncodeBasicDataVersionAndReservedAreZero(t *testing.T) { + t.Parallel() + + got, err := pbinEncodeBasicData(0xFFFFFFFFFFFFFFFF, uint256.NewInt(0), 0xFFFFFFFF) + require.NoError(t, err) + require.Equal(t, byte(0), got[0], "version") + require.Equal(t, []byte{0, 0, 0}, got[1:4], "reserved") +} + +func TestPBinEncodeBasicDataBalanceOverflow(t *testing.T) { + t.Parallel() + + twoPow128 := new(uint256.Int).Lsh(uint256.NewInt(1), 128) + + _, err := pbinEncodeBasicData(0, twoPow128, 0) + require.ErrorIs(t, err, errPBinBalanceOverflow) + + _, err = pbinEncodeBasicData(0, new(uint256.Int).Sub(twoPow128, uint256.NewInt(1)), 0) + require.NoError(t, err, "2^128-1 is the largest representable balance") + + _, err = pbinEncodeBasicData(0, new(uint256.Int).SetAllOne(), 0) + require.ErrorIs(t, err, errPBinBalanceOverflow) +} + +func TestPBinEncodeBasicDataCodeSizeOverflow(t *testing.T) { + t.Parallel() + + _, err := pbinEncodeBasicData(0, uint256.NewInt(0), 1<<32) + require.ErrorIs(t, err, errPBinCodeSizeOverflow) + + _, err = pbinEncodeBasicData(0, uint256.NewInt(0), 1<<32-1) + require.NoError(t, err) +} + +func TestPBinCodeHashValue(t *testing.T) { + t.Parallel() + + // keccak256("") — what a codeless account's CODE_HASH leaf holds. + const emptyCodeHash = "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + + t.Run("contract code hash passes through", func(t *testing.T) { + t.Parallel() + h := common.HexToHash("0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + got := pbinCodeHashValue(h) + require.Equal(t, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", hex.EncodeToString(got[:])) + }) + + t.Run("zero hash becomes the empty-code hash", func(t *testing.T) { + t.Parallel() + got := pbinCodeHashValue(common.Hash{}) + require.Equal(t, emptyCodeHash, hex.EncodeToString(got[:])) + }) + + t.Run("empty-code hash passes through", func(t *testing.T) { + t.Parallel() + got := pbinCodeHashValue(common.HexToHash("0x" + emptyCodeHash)) + require.Equal(t, emptyCodeHash, hex.EncodeToString(got[:])) + }) +} + +func TestPBinEncodeStorageValue(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + value string + want string + }{ + { + name: "absent value is 32 zero bytes", + value: "", + want: "0000000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "one byte is left-padded", + value: "05", + want: "0000000000000000000000000000000000000000000000000000000000000005", + }, + { + name: "short value keeps its byte order", + value: "0102", + want: "0000000000000000000000000000000000000000000000000000000000000102", + }, + { + name: "full-width value passes through", + value: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + want: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + }, + { + name: "leading zero byte is preserved", + value: "0002030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + want: "0002030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + raw, err := hex.DecodeString(tc.value) + require.NoError(t, err) + got := pbinEncodeStorageValue(raw) + require.Equal(t, tc.want, hex.EncodeToString(got[:])) + require.Len(t, got, pbinValueLength) + }) + } +} + +func TestPBinEncodeStorageValueRejectsOversizedValue(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { pbinEncodeStorageValue(make([]byte, 33)) }) +} diff --git a/execution/commitment/pbin_variant_test.go b/execution/commitment/pbin_variant_test.go new file mode 100644 index 00000000000..05c420eefec --- /dev/null +++ b/execution/commitment/pbin_variant_test.go @@ -0,0 +1,223 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// The binary engine runs in ModeDirect whatever mode the caller asks for: +// ModeParallel's prefix trie is a hex-nibble structure with no meaning at +// arity 2. +func TestInitializeTrieAndUpdates_BinVariant(t *testing.T) { + t.Parallel() + + cfg := DefaultTrieConfig() + cfg.Variant = VariantBinPatriciaTrie + trie, upd := InitializeTrieAndUpdates(ModeParallel, t.TempDir(), cfg) + defer upd.Close() + defer trie.Release() + + require.IsType(t, (*PBinPatriciaHashed)(nil), trie) + require.Equal(t, VariantBinPatriciaTrie, trie.Variant()) + require.Equal(t, ModeDirect, upd.Mode()) + require.Nil(t, upd.parallel) + require.False(t, upd.IsConcurrentCommitment()) +} + +func TestParseTrieVariantBin(t *testing.T) { + t.Parallel() + + require.Equal(t, VariantBinPatriciaTrie, ParseTrieVariant("bin")) + require.Equal(t, VariantHexPatriciaTrie, ParseTrieVariant("hex")) + require.Equal(t, VariantParallelHexPatricia, ParseTrieVariant("parallel")) +} + +// A run over a populated state depends only on what the context holds: an engine +// that dropped its in-memory root and one that never had it must both reproduce +// the root of the run that built it. +func TestPBinResetReuse(t *testing.T) { + t.Parallel() + + corpus := pbinTestMixedCorpus() + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + + want := corpus.oracleRoot(t) + require.Equal(t, want, pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates)) + + pph.Reset() + require.Equal(t, want, pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates), "reset engine re-reads the tree from the context") + + fresh := NewPBinPatriciaHashed(ms) + require.Equal(t, want, pbinTestProcess(t, fresh, corpus.plainKeys, corpus.updates), "fresh engine over the same state agrees") +} + +// Re-running the whole corpus hides this case: after Reset the engine must find +// the leaves it is not told about again. A tree confined to one zone has a +// non-empty root prefix, so its top record is not at the zero-bit key and only +// the root cell record names it. +func TestPBinResetReuseTouchingOneKey(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(71) + corpus := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + want := corpus.oracleRoot(t) + require.Equal(t, want, pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates)) + + touchOne := new(pbinTestCorpus).storage(addr, pbinOracleSlot(257), 0x02) + + pph.Reset() + require.Equal(t, want, pbinTestProcess(t, pph, touchOne.plainKeys, touchOne.updates), + "the untouched sibling must survive a reset") + + fresh := NewPBinPatriciaHashed(ms) + require.Equal(t, want, pbinTestProcess(t, fresh, touchOne.plainKeys, touchOne.updates)) +} + +// A one-leaf tree writes no node record at all: it lives entirely in the root +// cell record. +func TestPBinResetReuseSingleLeaf(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(72) + first := new(pbinTestCorpus).storage(addr, pbinOracleSlot(1000), 0x01) + second := new(pbinTestCorpus).storage(pbinOracleAddr(73), pbinOracleSlot(2000), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(first.plainKeys, first.updates)) + require.Equal(t, first.oracleRoot(t), pbinTestProcess(t, pph, first.plainKeys, first.updates)) + + require.NoError(t, ms.applyPlainUpdates(second.plainKeys, second.updates)) + both := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(1000), 0x01). + storage(pbinOracleAddr(73), pbinOracleSlot(2000), 0x02) + + pph.Reset() + require.Equal(t, both.oracleRoot(t), pbinTestProcess(t, pph, second.plainKeys, second.updates), + "the leaf that was the whole tree must survive a reset") +} + +// Reset leaves the engine indistinguishable from a new one but keeps the +// context, which the Trie interface hands over separately. +func TestPBinResetClearsTrieState(t *testing.T) { + t.Parallel() + + corpus := new(pbinTestCorpus). + storage(pbinOracleAddr(1), pbinOracleSlot(256), 0x01). + storage(pbinOracleAddr(1), pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + require.Equal(t, pbinNodeBranch, pph.grid.root.kind) + + pph.Reset() + require.Equal(t, pbinNodeEmpty, pph.grid.root.kind) + require.Zero(t, pph.currentKey.bitLen) + require.Zero(t, pph.grid.activeRows) + require.False(t, pph.rootChecked) + require.False(t, pph.rootTouched) + require.False(t, pph.rootPresent) + require.Same(t, ms, pph.ctx) +} + +// The domain layer asks for the root without processing anything, so RootHash +// has to reach the stored tree rather than report the empty-tree hash. +func TestPBinRootHashAfterResetLoadsStoredRoot(t *testing.T) { + t.Parallel() + + corpus := new(pbinTestCorpus). + storage(pbinOracleAddr(81), pbinOracleSlot(256), 0x01). + storage(pbinOracleAddr(81), pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + want := corpus.oracleRoot(t) + require.Equal(t, want, pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates)) + + pph.Reset() + root, err := pph.RootHash() + require.NoError(t, err) + require.Equal(t, want, root) + + fresh := NewPBinPatriciaHashed(ms) + empty, err := fresh.Process(context.Background(), WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), nil, nil), "", nil, WarmupConfig{}) + require.NoError(t, err) + require.Equal(t, want, empty, "a run with no updates must not shrink the tree to empty") +} + +func TestPBinResetContext(t *testing.T) { + t.Parallel() + + corpus := new(pbinTestCorpus).account(pbinOracleAddr(7), 1, 2, common.Hash{0x07}) + + pph, _ := pbinTestEngine(t) + other := NewMockState(t) + require.NoError(t, other.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + + pph.ResetContext(other) + require.Same(t, other, pph.ctx) + require.Equal(t, corpus.oracleRoot(t), pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates)) +} + +// A released engine goes back to the pool, so it must carry no state into the +// next run over a different context. +func TestPBinReleaseReuse(t *testing.T) { + t.Parallel() + + corpus := pbinTestMixedCorpus() + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + pph.Release() + + next := NewMockState(t) + require.NoError(t, next.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + reused := NewPBinPatriciaHashed(next) + require.Equal(t, corpus.oracleRoot(t), pbinTestProcess(t, reused, corpus.plainKeys, corpus.updates)) +} + +func TestPBinSetTraceWriter(t *testing.T) { + t.Parallel() + + corpus := pbinTestDeepSharedPrefixCorpus() + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + + var trace bytes.Buffer + pph.SetTraceWriter(&trace) + pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + require.Contains(t, trace.String(), "splitsInsidePrefix=") + require.Contains(t, trace.String(), "materializeReads=") + + trace.Reset() + pph.SetTraceWriter(nil) + pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + require.Empty(t, trace.String()) +} diff --git a/execution/commitment/pbin_verify_test.go b/execution/commitment/pbin_verify_test.go new file mode 100644 index 00000000000..ac0b10bbc69 --- /dev/null +++ b/execution/commitment/pbin_verify_test.go @@ -0,0 +1,418 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/length" +) + +// pbinVerifier rebuilds the tree from the records the engine wrote, reading +// nothing out of the engine's own cells. Where the oracle answers "is this the +// right root for these leaves", this answers "is what landed in the database the +// tree that root came from". +// +// It returns errors rather than failing the test directly, so a test can also +// pin that a corrupted record is caught. +type pbinVerifier struct { + t *testing.T + ms *MockState +} + +var ( + errPBinVerifyNoRecords = errors.New("pbin verify: no branch records") + errPBinVerifyPosition = errors.New("pbin verify: leaf sits where its key does not") +) + +// recordPaths decodes the key of every live node record. A record with no data +// is a deletion and names no node; the root cell record is keyed outside the +// bit-path space and is read through rootCell. +func (v *pbinVerifier) recordPaths() ([]pbinBitpath, error) { + paths := make([]pbinBitpath, 0, len(v.ms.cm)) + for key, data := range v.ms.cm { + if len(data) == 0 || key == string(pbinRootKey) { + continue + } + p, err := pbinDecodeBitPath([]byte(key)) + if err != nil { + return nil, fmt.Errorf("pbin verify: record key %x: %w", key, err) + } + paths = append(paths, p) + } + return paths, nil +} + +// rootPath is the record with no record above it. Records are keyed by the full +// descent path, so one record's path being a bit-prefix of another's is exactly +// the ancestor relation. +func (v *pbinVerifier) rootPath() (pbinBitpath, error) { + paths, err := v.recordPaths() + if err != nil { + return pbinBitpath{}, err + } + if len(paths) == 0 { + return pbinBitpath{}, errPBinVerifyNoRecords + } + var roots []pbinBitpath + for _, p := range paths { + covered := false + for _, q := range paths { + if q.bitLen < p.bitLen && p.hasPrefix(&q) { + covered = true + break + } + } + if !covered { + roots = append(roots, p) + } + } + if len(roots) != 1 { + return pbinBitpath{}, fmt.Errorf("pbin verify: %d of %d records have no ancestor, want 1", len(roots), len(paths)) + } + return roots[0], nil +} + +func (v *pbinVerifier) rootCell() (pbinCell, error) { + var c pbinCell + data, _, err := v.ms.Branch(pbinRootKey) + if err != nil { + return c, err + } + if len(data) == 0 { + return c, errPBinVerifyNoRecords + } + pos, err := pbinDecodeCell(data, 0, &c) + if err != nil { + return c, fmt.Errorf("pbin verify: root cell: %w", err) + } + if pos != len(data) { + return c, fmt.Errorf("pbin verify: %d trailing bytes after the root cell", len(data)-pos) + } + return c, nil +} + +// recomputeRoot hashes the record set bottom up, entering at the stored root +// cell rather than guessing which record has no ancestor. +func (v *pbinVerifier) recomputeRoot() ([]byte, error) { + c, err := v.rootCell() + if err != nil { + return nil, err + } + var start pbinBitpath + return v.cellHash(&start, &c) +} + +func (v *pbinVerifier) nodeHash(nodePath, prefix *pbinBitpath) ([]byte, error) { + cells, err := v.recordAt(nodePath) + if err != nil { + return nil, err + } + var children [2][]byte + for bit := range children { + start := *nodePath + start.appendBit(uint64(bit)) + if children[bit], err = v.cellHash(&start, &cells[bit]); err != nil { + return nil, err + } + } + return pbinTestKeccak(v.t, []byte{pbinBranchTag}, + pbinOracleEncodeBitPrefix(pbinVerifyBits(prefix)), children[0], children[1]), nil +} + +func (v *pbinVerifier) cellHash(start *pbinBitpath, c *pbinCell) ([]byte, error) { + switch c.kind { + case pbinNodeLeaf: + key, value, err := v.leaf(start, c) + if err != nil { + return nil, err + } + return pbinTestKeccak(v.t, []byte{pbinLeafTag}, key, value), nil + case pbinNodeBranch: + nodePath := *start + nodePath.append(&c.prefix) + return v.nodeHash(&nodePath, &c.prefix) + default: + return nil, fmt.Errorf("pbin verify: cell at %d bits has no node kind", start.bitLen) + } +} + +// leaf resolves a leaf cell to the key its position spells and the value its +// plain key holds in state. +func (v *pbinVerifier) leaf(start *pbinBitpath, c *pbinCell) (key, value []byte, err error) { + full := *start + full.append(&c.prefix) + if full.bitLen != pbinAccountKeyLength*8 && full.bitLen != pbinStorageKeyLength*8 { + return nil, nil, fmt.Errorf("pbin verify: leaf key of %d bits is neither zone length", full.bitLen) + } + key = pbinVerifyPackBits(pbinVerifyBits(&full)) + + update, err := v.plainState(c) + if err != nil { + return nil, nil, err + } + encoded, err := pbinLeafValue(key, update) + if err != nil { + return nil, nil, err + } + return key, encoded[:], nil +} + +func (v *pbinVerifier) plainState(c *pbinCell) (*Update, error) { + switch { + case c.accountAddrLen > 0 && c.storageAddrLen > 0: + return nil, errors.New("pbin verify: leaf carries both an account and a storage plain key") + case c.accountAddrLen > 0: + return v.ms.Account(c.accountAddr[:c.accountAddrLen]) + case c.storageAddrLen > 0: + return v.ms.Storage(c.storageAddr[:c.storageAddrLen]) + default: + // A code chunk has no plain key and no state behind it: the record is the + // only place its value exists, so the check is that it round-tripped. + return &c.Update, nil + } +} + +func (v *pbinVerifier) recordAt(nodePath *pbinBitpath) ([2]pbinCell, error) { + var cells [2]pbinCell + key := pbinEncodeBitPath(nodePath) + data, _, err := v.ms.Branch(key) + if err != nil { + return cells, err + } + if len(data) == 0 { + return cells, fmt.Errorf("pbin verify: no record for the %d-bit node at %x", nodePath.bitLen, key) + } + _, afterMap, err := pbinDecodeBranch(data, &cells) + if err != nil { + return cells, fmt.Errorf("pbin verify: record at %x: %w", key, err) + } + if afterMap != pbinCellBits { + return cells, fmt.Errorf("pbin verify: record at %x keeps %02b of its children, want both", key, afterMap) + } + return cells, nil +} + +// checkPlainKeys asserts every reachable stored leaf sits where its own key +// derivation puts it: the record's path, the child bit and the cell's prefix +// must spell exactly treeKey(plainKey). A slot routed into the wrong zone still +// builds a tree that hashes consistently, so position against derivation is +// what catches it. The walk starts at the stored root cell: a dropped subtree's +// records stay behind unreferenced — nothing enumerates them to delete them — +// so the reachable set is the tree. +func (v *pbinVerifier) checkPlainKeys() (int, error) { + root, err := v.rootCell() + if err != nil { + return 0, err + } + var start pbinBitpath + return v.checkCellLeaves(&start, &root) +} + +func (v *pbinVerifier) checkCellLeaves(start *pbinBitpath, c *pbinCell) (int, error) { + switch c.kind { + case pbinNodeLeaf: + if err := v.checkLeafPosition(start, c); err != nil { + return 0, err + } + return 1, nil + case pbinNodeBranch: + nodePath := *start + nodePath.append(&c.prefix) + cells, err := v.recordAt(&nodePath) + if err != nil { + return 0, err + } + leaves := 0 + for bit := range cells { + childStart := nodePath + childStart.appendBit(uint64(bit)) + n, err := v.checkCellLeaves(&childStart, &cells[bit]) + if err != nil { + return 0, err + } + leaves += n + } + return leaves, nil + default: + return 0, fmt.Errorf("pbin verify: cell at %d bits has no node kind", start.bitLen) + } +} + +func (v *pbinVerifier) checkLeafPosition(start *pbinBitpath, c *pbinCell) error { + key, _, err := v.leaf(start, c) + if err != nil { + return err + } + want, err := pbinVerifyDerivedKey(c, key) + if err != nil { + return err + } + if !bytes.Equal(want, key) { + return fmt.Errorf("%w: stored at %x, derives %x", errPBinVerifyPosition, key, want) + } + return nil +} + +// pbinVerifyDerivedKey re-derives a leaf's tree key from its plain key. The +// sub-index comes from the stored key because the two account-header leaves share +// one address; which of the two it is, the record does not say. +func pbinVerifyDerivedKey(c *pbinCell, key []byte) ([]byte, error) { + switch { + case c.accountAddrLen > 0: + if len(key) != pbinAccountKeyLength { + return nil, fmt.Errorf("pbin verify: account leaf key of %d bytes, want %d", len(key), pbinAccountKeyLength) + } + subIndex := key[pbinAccountKeyLength-1] + if subIndex != pbinBasicDataLeafKey && subIndex != pbinCodeHashLeafKey { + return nil, fmt.Errorf("pbin verify: account leaf at sub-index %d is neither header leaf", subIndex) + } + return pbinTreeKeyAccount(c.accountAddr[:c.accountAddrLen], subIndex), nil + case c.storageAddrLen > 0: + addr, slot := c.storageAddr[:length.Addr], c.storageAddr[length.Addr:c.storageAddrLen] + return pbinTreeKeyStorage(addr, slot), nil + default: + // A record-resident leaf holds no plain key to re-derive from, so what is + // checked is where it may sit: a code chunk in the code zone, or a + // delegation indicator at its header sub-index — never anywhere else. + if len(key) == pbinAccountKeyLength && key[0] == pbinAccountZone && key[pbinAccountKeyLength-1] == pbinDelegationLeafKey { + return key, nil + } + if len(key) != pbinCodeKeyLength || key[0] != pbinCodeZone { + return nil, fmt.Errorf("%w: value-carrying leaf at %x is neither code chunk nor delegation leaf", errPBinVerifyPosition, key) + } + return key, nil + } +} + +// pbinVerifyBits spells a path one bit per byte, the shape the oracle's +// encode_bit_prefix takes. +func pbinVerifyBits(p *pbinBitpath) []byte { + out := make([]byte, p.bitLen) + for i := range out { + out[i] = byte(p.bit(int16(i))) + } + return out +} + +func pbinVerifyPackBits(bits []byte) []byte { + out := make([]byte, (len(bits)+7)/8) + for i, b := range bits { + out[i/8] |= b << (7 - i%8) + } + return out +} + +// pbinTestVerifyRecords requires the records to rebuild the root the engine +// returned, with every leaf they hold sitting at its own key. +func pbinTestVerifyRecords(t *testing.T, ms *MockState, root []byte, wantLeaves int) { + t.Helper() + v := &pbinVerifier{t: t, ms: ms} + + recomputed, err := v.recomputeRoot() + require.NoError(t, err) + require.Equal(t, root, recomputed, "records do not rebuild the engine's root") + + leaves, err := v.checkPlainKeys() + require.NoError(t, err) + require.Equal(t, wantLeaves, leaves) +} + +// The recompute relies on this shape: one record has no ancestor, and its path +// is the root node's prefix. +func TestPBinVerifyRootRecordIsUnique(t *testing.T) { + t.Parallel() + + corpus := new(pbinTestCorpus). + storage(pbinOracleAddr(54), pbinOracleSlot(256), 0x01). + storage(pbinOracleAddr(54), pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + + v := &pbinVerifier{t: t, ms: ms} + root, err := v.rootPath() + require.NoError(t, err) + require.Equal(t, pph.grid.root.prefix, root, "the root record's path is the root node's prefix") +} + +// A bare-leaf root writes no node record and is still recoverable, because the +// root cell record carries it. +func TestPBinVerifySingleLeafRoot(t *testing.T) { + t.Parallel() + + corpus := new(pbinTestCorpus).storage(pbinOracleAddr(55), pbinOracleSlot(1000), 0x01) + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + root := pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + + v := &pbinVerifier{t: t, ms: ms} + paths, err := v.recordPaths() + require.NoError(t, err) + require.Empty(t, paths, "a bare-leaf root has no node record") + + pbinTestVerifyRecords(t, ms, root, 1) +} + +func TestPBinVerifyEmptyStateHasNoRecords(t *testing.T) { + t.Parallel() + + v := &pbinVerifier{t: t, ms: NewMockState(t)} + _, err := v.recomputeRoot() + require.ErrorIs(t, err, errPBinVerifyNoRecords) +} + +// Swapping a record's two children moves each leaf to a position its key does +// not spell, which the plain-key check must reject and the recompute must no +// longer reproduce. Without it both checks could be vacuous. +func TestPBinVerifyCatchesSwappedCells(t *testing.T) { + t.Parallel() + + corpus := new(pbinTestCorpus). + storage(pbinOracleAddr(56), pbinOracleSlot(256), 0x01). + storage(pbinOracleAddr(56), pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(corpus.plainKeys, corpus.updates)) + root := pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + pbinTestVerifyRecords(t, ms, root, len(corpus.entries(t))) + + v := &pbinVerifier{t: t, ms: ms} + path, err := v.rootPath() + require.NoError(t, err) + cells, err := v.recordAt(&path) + require.NoError(t, err) + + cells[0], cells[1] = cells[1], cells[0] + var enc pbinBranchEncoder + swapped, err := enc.encode(pbinCellBits, pbinCellBits, &cells) + require.NoError(t, err) + require.NoError(t, ms.PutBranch(pbinEncodeBitPath(&path), bytes.Clone(swapped), nil)) + + _, err = v.checkPlainKeys() + require.ErrorIs(t, err, errPBinVerifyPosition) + + recomputed, err := v.recomputeRoot() + require.NoError(t, err) + require.NotEqual(t, root, recomputed) +} diff --git a/execution/commitment/pbin_witness.go b/execution/commitment/pbin_witness.go new file mode 100644 index 00000000000..124f565968c --- /dev/null +++ b/execution/commitment/pbin_witness.go @@ -0,0 +1,126 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "fmt" + + "github.com/erigontech/erigon/common" +) + +// Witness capture for the binary trie. The tap sits in pbinHasher, not in the +// fold: sibling cells (hashRowCell) and the root cell (RootHash) are hashed +// outside foldBranch, and a fold-level tap would miss them. + +// emitNode hands a node's consensus preimage and hash to the tracer. The +// preimage is pbinHasher's scratch buffer, overwritten by the next hash, so a +// tracer that keeps it must copy. +func (h *pbinHasher) emitNode(preimage []byte, hash *common.Hash) { + if h.tracer == nil { + return + } + h.tracer.onNode(preimage, hash[:]) +} + +// setWitnessTracer taps every node this engine hashes. Reset detaches, so it +// must be called after any reset and never survives into a pooled reuse. +func (pph *PBinPatriciaHashed) setWitnessTracer(tracer witnessTracer) { + pph.hasher.tracer = tracer +} + +// pbinWitnessReadOnly drops the branch writes a fold makes on its way up. The +// witness pass folds rows it never modified, so writing them back would rewrite +// stored records under this pass's empty touch map. +type pbinWitnessReadOnly struct{ PatriciaContext } + +func (pbinWitnessReadOnly) PutBranch(prefix, data, prevData []byte) error { return nil } + +// Code forwards the code seam the update stream reaches for by type assertion; +// without it the wrapper would hide the wrapped context's own Code. +func (c pbinWitnessReadOnly) Code(plainKey []byte) ([]byte, error) { + inner, ok := c.PatriciaContext.(pbinCodeContext) + if !ok { + return nil, fmt.Errorf("%w: %T serves no code", ErrPBinUnsupported, c.PatriciaContext) + } + return inner.Code(plainKey) +} + +// PBinWitnessBlock is what a witness pass cannot read out of the parent state: +// the code the block writes, whose chunk keys would otherwise go unwalked, and +// the accounts it removes, which are indistinguishable there from accounts it +// creates. Both are keyed by account plain key. +type PBinWitnessBlock struct { + Code map[string][]byte + Removed map[string]struct{} +} + +// SetWitnessBlock supplies the next witness pass with what the parent state +// cannot say. Cleared when Witnesses returns. +func (pph *PBinPatriciaHashed) SetWitnessBlock(b PBinWitnessBlock) { + pph.updateStream.witness = b +} + +// Witnesses walks the tree along every key the update stream expands to, taps +// each node as it is hashed, and returns the captured superset (root first), the +// keys walked, and the root hash. Callers prune to the lean set. +// +// No update is applied: the caller checks the returned root against the parent +// block's, so it must be the pre-state one. +// +// produceExclusionProofs is accepted and ignored. It materializes the branch an +// extension node hides, and EIP-8297 has no extension node. The collapse +// survivors a removal re-hashes are captured unconditionally instead — see +// captureBranchPreimage. +func (pph *PBinPatriciaHashed) Witnesses(ctx context.Context, updates *Updates, produceExclusionProofs bool, logPrefix string) (nodes [][]byte, provedKeys [][]byte, rootHash []byte, err error) { + set := newWitnessNodeSet() + pph.setWitnessTracer(set) + defer pph.setWitnessTracer(nil) + pph.updateStream.witnessPass = true + defer func() { pph.updateStream.witness, pph.updateStream.witnessPass = PBinWitnessBlock{}, false }() + + stateCtx := pph.ctx + pph.ctx = pbinWitnessReadOnly{PatriciaContext: stateCtx} + defer func() { pph.ctx = stateCtx }() + + pph.lastKeyLen = 0 + provedKeys = make([][]byte, 0, updates.Size()) + // The proved keys are the stream's, not HashSort's: one account touch expands + // into a BASIC_DATA leaf, a CODE_HASH leaf and one leaf per code chunk, and + // only the sink sees all of them. + _, err = pph.updateStream.process(ctx, updates, pph.ctx, func(treeKey, _ []byte, _ *Update) error { + provedKeys = append(provedKeys, bytes.Clone(treeKey)) + _, err := pph.seek(treeKey) + return err + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("pbin: witness %s: %w", logPrefix, err) + } + for pph.grid.activeRows > 0 { + if err = pph.fold(); err != nil { + return nil, nil, nil, fmt.Errorf("pbin: witness final fold: %w", err) + } + } + if rootHash, err = pph.RootHash(); err != nil { + return nil, nil, nil, err + } + if nodes, err = set.nodes(rootHash); err != nil { + return nil, nil, nil, err + } + return nodes, provedKeys, rootHash, nil +} diff --git a/execution/commitment/pbin_witness_codezone_test.go b/execution/commitment/pbin_witness_codezone_test.go new file mode 100644 index 00000000000..749828f7f5d --- /dev/null +++ b/execution/commitment/pbin_witness_codezone_test.go @@ -0,0 +1,314 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// Deploying a contract writes leaves into the content-addressed code zone. +// When another contract's chunks are already there, the new leaves split an +// existing subtree, and the witness has to carry the node they split. + +// pbinSpillingCode returns code of chunkCount chunks, distinct per seed so two +// accounts land on different code-zone stems. +func pbinSpillingCode(seed byte, chunkCount int) []byte { + code := bytes.Repeat([]byte{0x01}, 31*chunkCount) + code[0] = seed + return code +} + +// pbinDeployCorpus is one account deploying code, the shape a create block has: +// the account's leaves and its chunks all arrive at once. +func pbinDeployCorpus(addrSeed uint64, code []byte) *pbinTestCorpus { + c := new(pbinTestCorpus) + return c.accountWithCodeBytes(pbinOracleAddr(addrSeed), 1, 1, code) +} + +// pbinStreamKeys runs the update stream over state and returns the tree keys it +// expands to, in emission order. +func pbinStreamKeys(t *testing.T, state PatriciaContext, c *pbinTestCorpus, block PBinWitnessBlock, witness bool) []string { + t.Helper() + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), c.plainKeys, c.updates) + s := &pbinUpdateStream{witness: block, witnessPass: witness} + var keys []string + _, err := s.process(context.Background(), upd, state, func(treeKey, _ []byte, _ *Update) error { + keys = append(keys, hex.EncodeToString(treeKey)) + return nil + }) + require.NoError(t, err) + return keys +} + +// TestPBinWitnessCodeOverrideMatchesFoldKeys is the property the override exists +// for: a witness pass reading the parent state has to expand an account to the +// same tree keys the fold did against the state the block leaves behind. Without +// the override the parent has no code for a contract the block creates, and the +// chunk keys go missing. +func TestPBinWitnessCodeOverrideMatchesFoldKeys(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(72) + code := pbinSpillingCode(0xC0, 136) + deploy := pbinDeployCorpus(72, code) + + post := NewMockState(t) + require.NoError(t, post.applyPlainUpdates(deploy.plainKeys, deploy.updates)) + post.setCode(addr, code) + fold := pbinStreamKeys(t, post, deploy, PBinWitnessBlock{}, false) + + parent := NewMockState(t) + witness := pbinStreamKeys(t, parent, deploy, PBinWitnessBlock{Code: map[string][]byte{string(addr): code}}, true) + + require.Equal(t, fold, witness) + require.Len(t, fold, 3+136, "three header keys and one key per chunk") + + // Without it the parent state yields the account's header keys only. + require.Len(t, pbinStreamKeys(t, parent, deploy, PBinWitnessBlock{}, true), 3) +} + +// pbinWitnessStateFor commits the corpus, proves a touch of addr and decodes the +// pruned witness back into a readable state. +func pbinWitnessStateFor(t *testing.T, corpus *pbinTestCorpus, addr []byte) (*PBinWitnessState, [][]byte, []byte) { + t.Helper() + ms, parentRoot := pbinWitnessCommitted(t, corpus) + + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), [][]byte{addr}, []Update{{}}) + nodes, provedKeys, root := pbinWitnessesOf(t, ms, upd, false) + require.Equal(t, parentRoot, root) + + lean, err := PBinWitnessNodesForKeys(nodes, root, provedKeys) + require.NoError(t, err) + state, err := PBinNewWitnessState(lean, root) + require.NoError(t, err) + return state, lean, root +} + +// TestPBinWitnessDelegatedAccountIsPresent: a delegated account holds no +// CODE_HASH leaf, so the delegation leaf has to mark it present, with the code +// hash EXTCODEHASH defines — the keccak of the indicator bytes. +func TestPBinWitnessDelegatedAccountIsPresent(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(75) + indicator := append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{0x22}, 20)...) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 3, 700, indicator) + + state, _, _ := pbinWitnessStateFor(t, corpus, addr) + + acc, ok, err := state.Account(addr) + require.NoError(t, err) + require.True(t, ok, "the delegation leaf marks the account present") + require.Equal(t, uint64(3), acc.Nonce) + require.Equal(t, uint64(700), acc.Balance.Uint64()) + require.Equal(t, uint64(pbinDelegationCodeLength), acc.CodeSize) + require.Equal(t, common.Hash(keccak.Sum256(indicator)), acc.CodeHash) +} + +// TestPBinWitnessDelegatedAccountCarriesNoChunks: the indicator is the code, read +// straight from the header leaf — the witness holds no code-zone leaf for it. +func TestPBinWitnessDelegatedAccountCarriesNoChunks(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(76) + indicator := append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{0x33}, 20)...) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 5, indicator) + + state, lean, root := pbinWitnessStateFor(t, corpus, addr) + + code, ok, err := state.Code(addr) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, indicator, code, "the code is the leading code_size bytes of the delegation leaf") + + tree, err := pbinDecodeWitness(lean, root) + require.NoError(t, err) + for _, node := range tree.nodes { + if node.isLeaf() { + require.NotEqual(t, byte(pbinCodeZone), node.key[0], "a delegated account owns no code-zone leaf") + } + } +} + +// TestPBinWitnessReassemblesCodeAcrossGroups: chunk 256 lives under tree_index 1, +// a different code-zone stem than chunks 0-255. The read has to cross that group +// boundary and come back byte-for-byte. +func TestPBinWitnessReassemblesCodeAcrossGroups(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(77) + code := pbinSpillingCode(0xD0, pbinStemSubtreeWidth+1) + corpus := new(pbinTestCorpus).accountWithCodeBytes(addr, 1, 1, code) + + state, _, _ := pbinWitnessStateFor(t, corpus, addr) + + got, ok, err := state.Code(addr) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, code, got) +} + +// TestPBinWitnessDeployIntoPopulatedCodeZone: a witness for a deploy has to let +// a verifier reach the post-state root, whether or not the code zone already +// holds another contract's chunks. The empty-zone case passes on its own, so the +// populated one is what the shared subtree adds. +func TestPBinWitnessDeployIntoPopulatedCodeZone(t *testing.T) { + t.Parallel() + + const chunks = 136 + + for _, tc := range []struct { + name string + prior int // chunks the code zone already holds, 0 for an empty zone + }{ + {name: "empty code zone", prior: 0}, + {name: "prior contract of 136 chunks", prior: 136}, + {name: "prior contract of 129 chunks", prior: 129}, + {name: "prior contract of 256 chunks", prior: 256}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + prior := new(pbinTestCorpus) + if tc.prior > 0 { + prior = pbinDeployCorpus(70, pbinSpillingCode(0xA0, tc.prior)) + } + ms, parentRoot := pbinWitnessCommitted(t, prior) + + code := pbinSpillingCode(0xB0, chunks) + deploy := pbinDeployCorpus(71, code) + // The block is executed, so its code is readable, but its leaves are + // not in the tree the witness proves: that is the parent's. + ms.setCode(pbinOracleAddr(71), code) + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), deploy.plainKeys, deploy.updates) + nodes, provedKeys, root := pbinWitnessesOf(t, ms, upd, false) + require.Equal(t, parentRoot, root, "the witness pass must prove the pre-state") + + lean, err := PBinWitnessNodesForKeys(nodes, root, provedKeys) + require.NoError(t, err) + + state, err := PBinNewWitnessState(lean, root) + require.NoError(t, err) + state.SetCode(pbinOracleAddr(71), code) + + got, err := state.Root(context.Background(), deploy.plainKeys, deploy.updates) + require.NoError(t, err, "the witness must carry every node the deploy descends through") + + deploy.applyTo(t, ms) + applied := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), deploy.plainKeys, deploy.updates) + want, err := NewPBinPatriciaHashed(ms).Process(context.Background(), applied, "", nil, WarmupConfig{}) + require.NoError(t, err) + require.Equal(t, want, got) + }) + } +} + +// TestPBinWitnessAccountMissingCodeHashIsMalformed: a present account holds +// exactly one of the CODE_HASH and DELEGATION leaves, so a witness that proves +// both absent under a live BASIC_DATA leaf describes a state the tree cannot +// hold. Reading it as an absent account would recompute a wrong root instead. +func TestPBinWitnessAccountMissingCodeHashIsMalformed(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(77) + keys := pbinDigestCache{sum: pbinSelectedSum} + var value [pbinValueLength]byte + value[pbinBasicDataNonceOffset+7] = 1 + + preimage := append([]byte{pbinLeafTag}, keys.accountKey(addr, pbinBasicDataLeafKey)...) + preimage = append(preimage, value[:]...) + hasher := pbinHasher{sum: pbinSelectedSum} + root := hasher.hash(preimage) + + state, err := PBinNewWitnessState([][]byte{preimage}, root[:]) + require.NoError(t, err) + + _, _, err = state.Account(addr) + require.ErrorIs(t, err, errPBinWitnessNode) +} + +// TestPBinWitnessDelegationLeafPinsCodeSize: a DELEGATION leaf is a fixed shape, +// so its account's code_size is always the indicator length. A BASIC_DATA leaf +// claiming anything else describes a state the tree cannot hold, and reading it +// would report an account running code no one wrote. +func TestPBinWitnessDelegationLeafPinsCodeSize(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(78) + indicator := append([]byte{0xEF, 0x01, 0x00}, bytes.Repeat([]byte{0x44}, 20)...) + keys := pbinDigestCache{sum: pbinSelectedSum} + hasher := pbinHasher{sum: pbinSelectedSum} + + leaf := func(key []byte, value [pbinValueLength]byte) ([]byte, common.Hash) { + preimage := append(append([]byte{pbinLeafTag}, key...), value[:]...) + return preimage, hasher.hash(preimage) + } + + basicKey := keys.accountKey(addr, pbinBasicDataLeafKey) + var balance uint256.Int + balance.SetUint64(2) + basic, err := pbinEncodeBasicData(1, &balance, pbinDelegationCodeLength-1) + require.NoError(t, err) + basicNode, basicHash := leaf(basicKey, basic) + delegNode, delegHash := leaf(keys.accountKey(addr, pbinDelegationLeafKey), pbinEncodeDelegation(indicator)) + + // Sub-indices 0 and 2 diverge two bits before the end of the key. + prefix := pbinPathFromBits(basicKey, int16(8*len(basicKey)-2)) + branch := pbinAppendBitPrefix([]byte{pbinBranchTag}, &prefix) + branch = append(branch, basicHash[:]...) + branch = append(branch, delegHash[:]...) + root := hasher.hash(branch) + + state, err := PBinNewWitnessState([][]byte{branch, basicNode, delegNode}, root[:]) + require.NoError(t, err) + + _, _, err = state.Account(addr) + require.ErrorIs(t, err, errPBinWitnessNode) +} + +// TestPBinFoldIgnoresWitnessBlock: the block override is a witness-pass input. +// A fold that finds one left behind has to derive its chunk keys from state +// anyway — honouring it would commit a wrong root on the execution path. +func TestPBinFoldIgnoresWitnessBlock(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(73) + code := pbinSpillingCode(0xC1, 4) + deploy := pbinDeployCorpus(73, code) + + post := NewMockState(t) + require.NoError(t, post.applyPlainUpdates(deploy.plainKeys, deploy.updates)) + post.setCode(addr, code) + + stale := PBinWitnessBlock{ + Code: map[string][]byte{string(addr): pbinSpillingCode(0xC2, 9)}, + Removed: map[string]struct{}{string(addr): {}}, + } + require.Equal(t, + pbinStreamKeys(t, post, deploy, PBinWitnessBlock{}, false), + pbinStreamKeys(t, post, deploy, stale, false)) +} diff --git a/execution/commitment/pbin_witness_context.go b/execution/commitment/pbin_witness_context.go new file mode 100644 index 00000000000..ec396ba2c99 --- /dev/null +++ b/execution/commitment/pbin_witness_context.go @@ -0,0 +1,292 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" + "github.com/erigontech/erigon/db/kv" +) + +// A decoded witness served as a PatriciaContext, so PBinPatriciaHashed is itself +// the mutable trie a post-state root comes out of — leaf splitting, branch +// creation, BASIC_DATA packing and code chunking included — instead of a second +// binary trie written beside it. + +var ( + errPBinWitnessBlinded = errors.New("pbin: witness node is blinded") + errPBinWitnessNoState = errors.New("pbin: witness holds no state") +) + +// pbinWitnessContext turns node preimages into the branch records the engine +// unfolds. A record is derived on first read and cached; PutBranch replaces it, +// so a fold reads back what it wrote. +type pbinWitnessContext struct { + tree *pbinWitnessTree + records map[string][]byte + leaves map[string]Update + codes map[string][]byte + keys pbinDigestCache +} + +var ( + _ PatriciaContext = (*pbinWitnessContext)(nil) + _ pbinCodeContext = (*pbinWitnessContext)(nil) + _ pbinDerivedContext = (*pbinWitnessContext)(nil) +) + +func (c *pbinWitnessContext) pbinRecordsAreDerived() {} + +func pbinNewWitnessContext(tree *pbinWitnessTree) *pbinWitnessContext { + return &pbinWitnessContext{ + tree: tree, + records: make(map[string][]byte), + leaves: make(map[string]Update), + codes: make(map[string][]byte), + keys: pbinDigestCache{sum: pbinSelectedSum}, + } +} + +// setCode supplies bytecode the node set cannot hold: code a block deploys has +// no pre-state chunk leaves to reassemble. +func (c *pbinWitnessContext) setCode(plainKey, code []byte) { + c.codes[string(plainKey)] = bytes.Clone(code) +} + +func (c *pbinWitnessContext) Branch(prefix []byte) ([]byte, kv.Step, error) { + if record, ok := c.records[string(prefix)]; ok { + return record, 0, nil + } + record, err := c.deriveRecord(prefix) + if err != nil { + return nil, 0, err + } + c.records[string(prefix)] = record + return record, 0, nil +} + +func (c *pbinWitnessContext) PutBranch(prefix, data, prevData []byte) error { + c.records[string(prefix)] = bytes.Clone(data) + return nil +} + +func (c *pbinWitnessContext) Account(plainKey []byte) (*Update, error) { return c.leafState(plainKey) } + +func (c *pbinWitnessContext) Storage(plainKey []byte) (*Update, error) { return c.leafState(plainKey) } + +func (c *pbinWitnessContext) Code(plainKey []byte) ([]byte, error) { + if code, ok := c.codes[string(plainKey)]; ok { + return code, nil + } + code, err := c.codeFromLeaves(plainKey) + if err != nil { + return nil, err + } + if code == nil { + return nil, fmt.Errorf("%w: no code for %x", errPBinWitnessNoState, plainKey) + } + return code, nil +} + +// leafState resolves the handle a witness leaf cell carries in place of a plain +// key: the witness holds a leaf's value, never the address it was derived from. +// Anything else is refused — an empty read would hash a zeroed leaf into the +// root instead of failing. +func (c *pbinWitnessContext) leafState(plainKey []byte) (*Update, error) { + state, ok := c.leaves[string(plainKey)] + if !ok { + return nil, fmt.Errorf("%w for plain key %x", errPBinWitnessNoState, plainKey) + } + return &state, nil +} + +func (c *pbinWitnessContext) deriveRecord(prefix []byte) ([]byte, error) { + if bytes.Equal(prefix, pbinRootKey) { + return c.rootRecord() + } + path, err := pbinDecodeBitPath(prefix) + if err != nil { + return nil, err + } + node, err := c.nodeAt(&path) + if err != nil { + return nil, err + } + return c.branchRecord(&node, &path) +} + +// rootRecord holds the one cell no descent can name. An empty tree has no +// record at all, which is the only shape a caller may read as absent. +func (c *pbinWitnessContext) rootRecord() ([]byte, error) { + if c.tree.root == pbinEmptyTreeHash { + return []byte{}, nil + } + if _, ok := c.tree.nodes[c.tree.root]; !ok { + return nil, fmt.Errorf("%w: no preimage for root %x", errPBinWitnessBlinded, c.tree.root) + } + var cell pbinCell + cell.reset() + var path pbinBitpath + if err := c.fillCell(&cell, c.tree.root, &path); err != nil { + return nil, err + } + return pbinAppendCell(nil, &cell) +} + +func (c *pbinWitnessContext) branchRecord(node *pbinWitnessNode, path *pbinBitpath) ([]byte, error) { + if path.bitLen >= pbinMaxPathBits { + return nil, fmt.Errorf("%w: a branch at %d bits leaves no room for a child", + errPBinWitnessNode, path.bitLen) + } + var cells [2]pbinCell + for bit := range cells { + childPath := *path + childPath.appendBit(uint64(bit)) + cells[bit].reset() + if err := c.fillCell(&cells[bit], node.children[bit], &childPath); err != nil { + return nil, err + } + } + var encoder pbinBranchEncoder + // The touch map is write-time bookkeeping a read discards, so it says the same + // as the after map. + record, err := encoder.encode(pbinCellBits, pbinCellBits, &cells) + if err != nil { + return nil, err + } + return bytes.Clone(record), nil +} + +// fillCell describes one child of a branch. A child with no preimage is opaque: +// it hashes to what its parent commits to, and a descent into it fails in +// nodeAt, where the path is known. +func (c *pbinWitnessContext) fillCell(cell *pbinCell, hash common.Hash, path *pbinBitpath) error { + if hash == pbinEmptyTreeHash { + // A binary node with one child is a node the fold would collapse, quietly + // moving the root. + return fmt.Errorf("%w: branch child at bit %d is the empty tree", errPBinWitnessNode, path.bitLen) + } + node, ok := c.tree.nodes[hash] + if !ok || !node.isLeaf() { + cell.kind = pbinNodeBranch + if ok { + cell.prefix = node.prefix + } + cell.hash, cell.hashLen = hash, length.Hash + return nil + } + return c.fillLeafCell(cell, &node, hash, path) +} + +func (c *pbinWitnessContext) fillLeafCell(cell *pbinCell, node *pbinWitnessNode, hash common.Hash, path *pbinBitpath) error { + key := pbinPathFromBytes(node.key) + if !key.hasPrefix(path) { + return fmt.Errorf("%w: leaf %x does not sit under the %d-bit path it was reached by", + errPBinWitnessNode, node.key, path.bitLen) + } + cell.kind = pbinNodeLeaf + cell.prefix = key.slice(path.bitLen, key.bitLen) + + // A record holds a leaf value either verbatim or as the account fields it is + // packed from. Which one applies is decided by re-encoding, not by zone, so + // this cannot drift from pbinLeafValue. + verbatim := cell.Update + verbatim.Flags, verbatim.StorageLen = StorageUpdate, pbinValueLength + copy(verbatim.Storage[:], node.value) + if value, err := pbinLeafValue(node.key, &verbatim); err == nil && bytes.Equal(value[:], node.value) { + cell.Update = verbatim + return nil + } + + state, err := pbinWitnessLeafState(node.key, node.value) + if err != nil { + return err + } + handle := hash[:length.Addr] + if prev, seen := c.leaves[string(handle)]; seen && prev != state { + return fmt.Errorf("%w: two leaves share the handle %x", errPBinWitnessNode, handle) + } + c.leaves[string(handle)] = state + cell.accountAddrLen = length.Addr + copy(cell.accountAddr[:], handle) + return nil +} + +// pbinWitnessLeafState inverts the packing pbinLeafValue applies, for the leaves +// a record cannot carry verbatim: BASIC_DATA and CODE_HASH are built from +// account fields, so the cell has to hold those fields instead. The result is +// re-encoded before it is returned, which rejects any value the tree could not +// have produced. +func pbinWitnessLeafState(key, value []byte) (Update, error) { + var u Update + u.Reset() + if key[0] == pbinAccountZone { + switch key[len(key)-1] { + case pbinBasicDataLeafKey: + u.Flags = NonceUpdate | BalanceUpdate | CodeUpdate + u.CodeSize = uint64(binary.BigEndian.Uint32(value[pbinBasicDataCodeSizeOffset:])) + u.Nonce = binary.BigEndian.Uint64(value[pbinBasicDataNonceOffset:]) + u.Balance.SetBytes(value[pbinBasicDataBalanceOffset:]) + case pbinCodeHashLeafKey: + u.Flags = CodeUpdate + u.CodeHash = common.BytesToHash(value) + } + } + if u.Flags == 0 { + return u, fmt.Errorf("%w: leaf %x carries a value no record can hold", errPBinWitnessNode, key) + } + got, err := pbinLeafValue(key, &u) + if err != nil { + return u, err + } + if !bytes.Equal(got[:], value) { + return u, fmt.Errorf("%w: leaf %x holds %x, which no state packs to", errPBinWitnessNode, key, value) + } + return u, nil +} + +// nodeAt finds the node whose absolute path is p: the root node's path is its +// own prefix, and a child's is its parent's path, the bit it hangs off, and its +// own prefix. +func (c *pbinWitnessContext) nodeAt(p *pbinBitpath) (pbinWitnessNode, error) { + hash, pos := c.tree.root, int16(0) + for { + node, ok := c.tree.nodes[hash] + if !ok { + return node, fmt.Errorf("%w: no preimage for %x, reached at bit %d of the %d-bit path %x", + errPBinWitnessBlinded, hash, pos, p.bitLen, p.appendPackedBits(nil)) + } + if node.isLeaf() { + return node, fmt.Errorf("%w: a leaf covers bit %d of the %d-bit path %x", + errPBinWitnessNode, pos, p.bitLen, p.appendPackedBits(nil)) + } + end := pos + node.prefix.bitLen + if end > p.bitLen || pbinCommonPrefixBitsAt(p, pos, &node.prefix) != node.prefix.bitLen { + return node, fmt.Errorf("%w: no node at the %d-bit path %x", + errPBinWitnessNode, p.bitLen, p.appendPackedBits(nil)) + } + if end == p.bitLen { + return node, nil + } + hash, pos = node.children[p.bit(end)], end+1 + } +} diff --git a/execution/commitment/pbin_witness_context_test.go b/execution/commitment/pbin_witness_context_test.go new file mode 100644 index 00000000000..b2c4a76a8e3 --- /dev/null +++ b/execution/commitment/pbin_witness_context_test.go @@ -0,0 +1,197 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +// pbinWitnessContextCode is the code pbinWitnessCorpus commits for account 21. +// The pending set keeps it unchanged: the witness pass reads pre-state code and +// checks it against the update's code size, so a resized contract is a corpus +// the pass refuses before the context is ever reached. +func pbinWitnessContextCode() []byte { return bytes.Repeat([]byte{0x60}, 200) } + +// pbinWitnessContextPending touches a coded account, a fresh account and two +// slots, so the post-state pass has to split leaves, create branches, pack +// BASIC_DATA and chunk code over the witness alone. +func pbinWitnessContextPending() *pbinTestCorpus { + c := new(pbinTestCorpus) + c.accountWithCodeBytes(pbinOracleAddr(21), 5, 1500, pbinWitnessContextCode()) + c.account(pbinOracleAddr(23), 3, 300, common.Hash{0x23}) + c.storage(pbinOracleAddr(21), pbinOracleSlot(64), 0xEE) + c.storage(pbinOracleAddr(23), pbinOracleSlot(5), 0x55) + return c +} + +type pbinWitnessContextFixture struct { + state *MockState + pending *pbinTestCorpus + witness *pbinWitnessContext + tree *pbinWitnessTree + parentRoot []byte +} + +// pbinWitnessContextSetup commits a corpus, takes the witness of the pending +// updates against it, and hands back a context backed by nothing else. +func pbinWitnessContextSetup(t *testing.T) *pbinWitnessContextFixture { + t.Helper() + f := &pbinWitnessContextFixture{pending: pbinWitnessContextPending()} + f.state, f.parentRoot = pbinWitnessCommitted(t, pbinWitnessCorpus()) + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), f.pending.plainKeys, f.pending.updates) + nodes, _, root := pbinWitnessesOf(t, f.state, upd, false) + require.Equal(t, f.parentRoot, root) + + f.tree = pbinWitnessDecoded(t, nodes, root) + f.witness = pbinNewWitnessContext(f.tree) + for addr, code := range f.pending.codes { + f.witness.setCode([]byte(addr), code) + } + return f +} + +func (f *pbinWitnessContextFixture) apply(t *testing.T, ctx PatriciaContext) []byte { + t.Helper() + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), f.pending.plainKeys, f.pending.updates) + root, err := NewPBinPatriciaHashed(ctx).Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.NoError(t, err) + return root +} + +// TestPBinWitnessContextPostStateRoot is the point of the whole context: the +// engine applies the block's updates over the witness and reaches the root it +// reaches over full state, so no second mutable binary trie is needed. +func TestPBinWitnessContextPostStateRoot(t *testing.T) { + t.Parallel() + + f := pbinWitnessContextSetup(t) + got := f.apply(t, f.witness) + want := f.apply(t, f.state) + + require.Equal(t, want, got) + require.NotEqual(t, f.parentRoot, want, "the pending updates do not move the root, so the test proves nothing") +} + +// TestPBinWitnessContextProvesNothingItDoesNotHold: the witness stops at the +// touched paths, and the subtrees it leaves opaque are what the root still has +// to be recomputed through. +func TestPBinWitnessContextPartialWitness(t *testing.T) { + t.Parallel() + + f := pbinWitnessContextSetup(t) + _, blinded := pbinWitnessReachable(f.tree) + require.NotEmpty(t, blinded, "the witness holds every node, so it proves nothing about partial state") + + full, _ := pbinWitnessCapture(t, pbinWitnessCorpus()) + require.Less(t, len(f.tree.nodes), len(full), "the witness is not smaller than the whole tree") + + require.Equal(t, f.apply(t, f.state), f.apply(t, f.witness)) +} + +// TestPBinWitnessContextBlindedBranchErrors: a read that needs a node the +// witness left out must name the path and fail, never come back empty — an +// empty record reads as an absent subtree and builds a wrong root. +func TestPBinWitnessContextBlindedBranchErrors(t *testing.T) { + t.Parallel() + + f := pbinWitnessContextSetup(t) + path := pbinWitnessBlindedPath(t, f.tree) + + record, _, err := f.witness.Branch(pbinEncodeBitPath(&path)) + require.ErrorIs(t, err, errPBinWitnessBlinded) + require.Empty(t, record) + require.Contains(t, err.Error(), hex.EncodeToString(path.appendPackedBits(nil)), "the error does not name the path") +} + +// pbinWitnessBlindedPath walks to the first child the witness has no preimage +// for and returns its absolute path, which is the key the engine would read a +// record at. +func pbinWitnessBlindedPath(t *testing.T, w *pbinWitnessTree) pbinBitpath { + t.Helper() + var found pbinBitpath + var ok bool + var walk func(hash common.Hash, path pbinBitpath) + walk = func(hash common.Hash, path pbinBitpath) { + node, present := w.nodes[hash] + if !present || ok { + return + } + path.append(&node.prefix) + if node.isLeaf() { + return + } + for bit := range node.children { + child := path + child.appendBit(uint64(bit)) + if _, present := w.nodes[node.children[bit]]; !present { + found, ok = child, true + return + } + walk(node.children[bit], child) + } + } + walk(w.root, pbinBitpath{}) + require.True(t, ok, "the witness blinds no child") + return found +} + +// TestPBinWitnessContextRefusesUnknownState: the context serves the witness and +// nothing else. A plain key it never issued a handle for has no state, and +// answering with an empty update would hash a zeroed leaf into the root. +func TestPBinWitnessContextRefusesUnknownState(t *testing.T) { + t.Parallel() + + f := pbinWitnessContextSetup(t) + addr := pbinOracleAddr(21) + + _, err := f.witness.Account(addr) + require.ErrorIs(t, err, errPBinWitnessNoState) + + _, err = f.witness.Storage(append(bytes.Clone(addr), pbinOracleSlot(64)...)) + require.ErrorIs(t, err, errPBinWitnessNoState) + + _, err = f.witness.Code(pbinOracleAddr(99)) + require.ErrorIs(t, err, errPBinWitnessNoState) +} + +// TestPBinWitnessContextLeafHandlesRoundTrip: a BASIC_DATA leaf is packed from +// account fields, so a record carries those fields rather than the 32 bytes the +// witness holds. The handle the cell carries has to lead back to a state that +// packs to exactly those bytes. +func TestPBinWitnessContextLeafHandlesRoundTrip(t *testing.T) { + t.Parallel() + + f := pbinWitnessContextSetup(t) + f.apply(t, f.witness) + require.NotEmpty(t, f.witness.leaves, "no leaf needed a handle, so the packing path is untested") + + for handle, state := range f.witness.leaves { + update := state + got, err := f.witness.Account([]byte(handle)) + require.NoError(t, err) + require.Equal(t, &update, got) + require.False(t, got.Deleted()) + } +} diff --git a/execution/commitment/pbin_witness_decode.go b/execution/commitment/pbin_witness_decode.go new file mode 100644 index 00000000000..93492b24ba2 --- /dev/null +++ b/execution/commitment/pbin_witness_decode.go @@ -0,0 +1,202 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "encoding/binary" + "errors" + "fmt" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// Reading back the preimages pbinHasher emits. A witness arrives from a peer, so +// every field the writer guarantees is checked here rather than assumed. + +var errPBinWitnessNode = errors.New("pbin: malformed witness node") + +// pbinWitnessNode is one decoded preimage. preimage, key and value all alias the +// bytes the node was decoded from, so a consumer that outlives them must copy. +type pbinWitnessNode struct { + tag byte + preimage []byte + key []byte // leaf: the whole tree key + value []byte // leaf: pbinValueLength bytes + prefix pbinBitpath + children [2]common.Hash // branch: an absent child is pbinEmptyTreeHash +} + +func (n *pbinWitnessNode) isLeaf() bool { return n.tag == pbinLeafTag } + +func pbinDecodeWitnessNode(preimage []byte) (pbinWitnessNode, error) { + if len(preimage) == 0 { + return pbinWitnessNode{}, fmt.Errorf("%w: empty preimage", errPBinWitnessNode) + } + var ( + node pbinWitnessNode + err error + ) + switch preimage[0] { + case pbinLeafTag: + node, err = pbinDecodeWitnessLeaf(preimage) + case pbinBranchTag: + node, err = pbinDecodeWitnessBranch(preimage) + default: + err = fmt.Errorf("%w: unknown node tag %#x", errPBinWitnessNode, preimage[0]) + } + if err != nil { + return pbinWitnessNode{}, err + } + node.preimage = preimage + return node, nil +} + +func pbinDecodeWitnessLeaf(preimage []byte) (pbinWitnessNode, error) { + body := preimage[1:] + if len(body) <= pbinValueLength { + return pbinWitnessNode{}, fmt.Errorf("%w: leaf of %d bytes carries no key", errPBinWitnessNode, len(preimage)) + } + key := body[:len(body)-pbinValueLength] + // Key length is fixed per zone, which is what keeps the key space prefix-free + // (eip:"Tree embedding"). + if want, known := pbinZoneKeyLength(key[0]); !known || len(key) != want { + return pbinWitnessNode{}, fmt.Errorf("%w: leaf key %x is no key of zone %#x", errPBinWitnessNode, key, key[0]) + } + return pbinWitnessNode{tag: pbinLeafTag, key: key, value: body[len(body)-pbinValueLength:]}, nil +} + +func pbinDecodeWitnessBranch(preimage []byte) (pbinWitnessNode, error) { + const head = 1 + 2 // tag, then the bit count encode_bit_prefix leads with + if len(preimage) < head { + return pbinWitnessNode{}, fmt.Errorf("%w: branch of %d bytes carries no bit count", errPBinWitnessNode, len(preimage)) + } + bitLen := int(binary.BigEndian.Uint16(preimage[1:head])) + if bitLen > pbinMaxPathBits { + return pbinWitnessNode{}, fmt.Errorf("%w: branch prefix of %d bits exceeds the %d-bit path", errPBinWitnessNode, bitLen, pbinMaxPathBits) + } + packed := (bitLen + 7) / 8 + if want := head + packed + 2*length.Hash; len(preimage) != want { + return pbinWitnessNode{}, fmt.Errorf("%w: branch of %d bytes, want %d for a %d-bit prefix", errPBinWitnessNode, len(preimage), want, bitLen) + } + if used := bitLen % 8; used != 0 && preimage[head+packed-1]&(byte(0xFF)>>uint(used)) != 0 { + return pbinWitnessNode{}, fmt.Errorf("%w: %w in a %d-bit branch prefix", errPBinWitnessNode, errPBinNonCanonicalPad, bitLen) + } + n := pbinWitnessNode{ + tag: pbinBranchTag, + prefix: pbinPathFromBits(preimage[head:head+packed], int16(bitLen)), + } + children := preimage[head+packed:] + n.children[0] = common.BytesToHash(children[:length.Hash]) + n.children[1] = common.BytesToHash(children[length.Hash:]) + return n, nil +} + +// pbinWitnessTree is a decoded node set indexed by H(preimage), rooted at the +// hash the capture reported. +type pbinWitnessTree struct { + nodes map[common.Hash]pbinWitnessNode + root common.Hash + hasher pbinHasher +} + +// pbinDecodeWitness decodes a captured node set rooted at root. The root is +// given rather than taken from the slice, so preimages may arrive in any order: +// the witness an RPC consumer receives is sorted, and re-rooting the tree on +// whatever leads the slice would turn a lost root node into a wrong answer +// instead of an error. +func pbinDecodeWitness(preimages [][]byte, root []byte) (*pbinWitnessTree, error) { + if len(root) != length.Hash { + return nil, fmt.Errorf("%w: witness root of %d bytes", errPBinWitnessNode, len(root)) + } + w := &pbinWitnessTree{ + nodes: make(map[common.Hash]pbinWitnessNode, len(preimages)), + root: common.BytesToHash(root), + hasher: pbinHasher{sum: pbinSelectedSum}, + } + if len(preimages) == 0 { + if w.root != pbinEmptyTreeHash { + return nil, fmt.Errorf("%w: no nodes for root %x", errPBinWitnessNode, root) + } + return w, nil + } + for i, preimage := range preimages { + node, err := pbinDecodeWitnessNode(preimage) + if err != nil { + return nil, fmt.Errorf("witness node %d: %w", i, err) + } + w.nodes[w.hasher.hash(preimage)] = node + } + if _, ok := w.nodes[w.root]; !ok { + return nil, fmt.Errorf("%w: no node for root %x", errPBinWitnessNode, root) + } + return w, nil +} + +// merkelize rehashes the tree from its root, so a decode that lost anything +// fails here instead of downstream. A child hash the set has no preimage for is +// blinded: opaque, and carried up as it stands. +func (w *pbinWitnessTree) merkelize() (common.Hash, error) { + if len(w.nodes) == 0 { + return pbinEmptyTreeHash, nil + } + got, err := w.merkelizeFrom(w.root, 0) + if err != nil { + return common.Hash{}, err + } + if got != w.root { + return common.Hash{}, fmt.Errorf("%w: root %x re-merkelizes to %x", errPBinWitnessNode, w.root, got) + } + return got, nil +} + +// merkelizeFrom rehashes the subtree at hash, sitting at bit position depth. The +// depth bounds the recursion: a branch consumes its prefix plus the bit it +// splits on, so nodes referencing each other in a cycle run out of path rather +// than running forever. +func (w *pbinWitnessTree) merkelizeFrom(hash common.Hash, depth int16) (common.Hash, error) { + node, ok := w.nodes[hash] + if !ok { + return hash, nil + } + if node.isLeaf() { + return w.hasher.leafNodeHash(node.key, node.value), nil + } + next := depth + node.prefix.bitLen + 1 + if int(next) > pbinMaxPathBits { + return common.Hash{}, fmt.Errorf("%w: branch at bit %d with a %d-bit prefix overflows the %d-bit path", + errPBinWitnessNode, depth, node.prefix.bitLen, pbinMaxPathBits) + } + left, err := w.merkelizeFrom(node.children[0], next) + if err != nil { + return common.Hash{}, err + } + right, err := w.merkelizeFrom(node.children[1], next) + if err != nil { + return common.Hash{}, err + } + return w.hasher.branchHash(&node.prefix, &left, &right), nil +} + +// leafNodeHash is H over a leaf preimage built from a decoded key, where +// leafCellHash packs the key from a path and a cell. +func (h *pbinHasher) leafNodeHash(key, value []byte) common.Hash { + buf := append(h.buf[:0], pbinLeafTag) + buf = append(buf, key...) + buf = append(buf, value...) + return h.hash(buf) +} diff --git a/execution/commitment/pbin_witness_decode_test.go b/execution/commitment/pbin_witness_decode_test.go new file mode 100644 index 00000000000..79b1c2c1e1a --- /dev/null +++ b/execution/commitment/pbin_witness_decode_test.go @@ -0,0 +1,325 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// pbinWitnessCapture folds the corpus with the node set attached, giving the +// root-first slice a witness carries. +func pbinWitnessCapture(t *testing.T, corpus *pbinTestCorpus) (nodes [][]byte, root []byte) { + t.Helper() + set := newWitnessNodeSet() + root, _ = pbinWitnessProcess(t, corpus, set) + root = bytes.Clone(root) + nodes, err := set.nodes(root) + require.NoError(t, err) + return nodes, root +} + +func pbinWitnessDecoded(t *testing.T, nodes [][]byte, root []byte) *pbinWitnessTree { + t.Helper() + w, err := pbinDecodeWitness(nodes, root) + require.NoError(t, err) + return w +} + +func pbinWitnessMerkelized(t *testing.T, nodes [][]byte, root []byte) []byte { + t.Helper() + got, err := pbinWitnessDecoded(t, nodes, root).merkelize() + require.NoError(t, err) + return got[:] +} + +// pbinWitnessReachable walks the decoded tree from its root, returning the nodes +// it reaches and the child hashes it could not resolve. Both are what a consumer +// of the witness actually sees; the captured set holds more. +func pbinWitnessReachable(w *pbinWitnessTree) (reached map[common.Hash]pbinWitnessNode, blinded []common.Hash) { + reached = make(map[common.Hash]pbinWitnessNode) + var walk func(hash common.Hash) + walk = func(hash common.Hash) { + node, ok := w.nodes[hash] + if !ok { + if hash != pbinEmptyTreeHash { + blinded = append(blinded, hash) + } + return + } + if _, seen := reached[hash]; seen { + return + } + reached[hash] = node + if !node.isLeaf() { + walk(node.children[0]) + walk(node.children[1]) + } + } + walk(w.root) + return reached, blinded +} + +// TestPBinDecodeWitnessNodeShapes pins the two preimage layouts against the +// reference transcription's encode_bit_prefix rather than against the encoder +// the engine uses. +func TestPBinDecodeWitnessNodeShapes(t *testing.T) { + t.Parallel() + + t.Run("leaf", func(t *testing.T) { + t.Parallel() + key := pbinTreeKeyAccount(pbinOracleAddr(1), pbinBasicDataLeafKey) + value := pbinOracleValue(9) + + node, err := pbinDecodeWitnessNode(slices.Concat([]byte{pbinLeafTag}, key, value)) + require.NoError(t, err) + require.True(t, node.isLeaf()) + require.Equal(t, key, node.key) + require.Equal(t, value, node.value) + }) + + t.Run("branch", func(t *testing.T) { + t.Parallel() + left, right := common.Hash{0xAA}, common.Hash{0xBB} + preimage := slices.Concat( + []byte{pbinBranchTag}, + pbinOracleEncodeBitPrefix([]byte{1, 0, 1}), + left[:], right[:]) + + node, err := pbinDecodeWitnessNode(preimage) + require.NoError(t, err) + require.False(t, node.isLeaf()) + require.Equal(t, pbinPathFromBits([]byte{0xA0}, 3), node.prefix) + require.Equal(t, [2]common.Hash{left, right}, node.children) + }) + + t.Run("branch with an empty prefix", func(t *testing.T) { + t.Parallel() + preimage := slices.Concat( + []byte{pbinBranchTag}, + pbinOracleEncodeBitPrefix(nil), + make([]byte, 2*length.Hash)) + + node, err := pbinDecodeWitnessNode(preimage) + require.NoError(t, err) + require.Equal(t, int16(0), node.prefix.bitLen) + require.Equal(t, [2]common.Hash{pbinEmptyTreeHash, pbinEmptyTreeHash}, node.children, + "an absent child is the empty-tree hash, never omitted") + }) +} + +// TestPBinDecodeWitnessNodeRejectsMalformed: a witness comes from a peer, so +// every one of these has to error rather than yield a node that hashes to +// something else. +func TestPBinDecodeWitnessNodeRejectsMalformed(t *testing.T) { + t.Parallel() + + key := pbinTreeKeyAccount(pbinOracleAddr(1), pbinBasicDataLeafKey) + value := pbinOracleValue(9) + children := make([]byte, 2*length.Hash) + + branch := func(bitLen uint16, packed []byte) []byte { + return slices.Concat([]byte{pbinBranchTag, byte(bitLen >> 8), byte(bitLen)}, packed, children) + } + + for _, tc := range []struct { + name string + preimage []byte + }{ + {name: "empty preimage", preimage: nil}, + {name: "unknown tag", preimage: slices.Concat([]byte{0x02}, key, value)}, + {name: "leaf without a key", preimage: slices.Concat([]byte{pbinLeafTag}, value)}, + {name: "leaf truncated inside its value", preimage: slices.Concat([]byte{pbinLeafTag}, key, value[:31])}, + {name: "leaf key of an unallocated zone", preimage: slices.Concat([]byte{pbinLeafTag, 0x02}, key[1:], value)}, + {name: "leaf key one byte short of its zone", preimage: slices.Concat([]byte{pbinLeafTag}, key[:len(key)-1], value)}, + {name: "leaf key one byte past its zone", preimage: slices.Concat([]byte{pbinLeafTag}, key, []byte{0}, value)}, + {name: "branch without a bit count", preimage: []byte{pbinBranchTag, 0x00}}, + {name: "branch prefix past the encodable path", preimage: branch(pbinMaxPathBits+1, make([]byte, 67))}, + {name: "branch prefix truncated", preimage: branch(16, []byte{0x00})}, + {name: "branch missing a child hash", preimage: slices.Concat([]byte{pbinBranchTag, 0, 0}, children[:length.Hash])}, + {name: "branch with a trailing byte", preimage: branch(0, []byte{0x00})}, + {name: "branch prefix padded non-canonically", preimage: branch(3, []byte{0xA1})}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := pbinDecodeWitnessNode(tc.preimage) + require.ErrorIs(t, err, errPBinWitnessNode) + }) + } +} + +// TestPBinWitnessDecodeRoundTrip: a captured fold decodes back into the leaves +// the corpus stands for and re-merkelizes to the root it was captured under. +func TestPBinWitnessDecodeRoundTrip(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + nodes, root := pbinWitnessCapture(t, corpus) + require.Equal(t, corpus.oracleRoot(t), root) + + w := pbinWitnessDecoded(t, nodes, root) + reached, blinded := pbinWitnessReachable(w) + require.Empty(t, blinded, "a fold from empty state hashes every node, so nothing is blinded") + + leaves := make(map[string][]byte) + branches := 0 + for _, node := range reached { + if node.isLeaf() { + leaves[string(node.key)] = node.value + continue + } + branches++ + } + require.Positive(t, branches) + require.Len(t, leaves, corpus.leafCount(t)) + for _, e := range corpus.entries(t) { + require.Equal(t, e.value, leaves[string(e.key)], "leaf %x", e.key) + } + + got, err := w.merkelize() + require.NoError(t, err) + require.Equal(t, root, got[:]) +} + +// TestPBinWitnessDecodeBlindedChild: the witness of a few touched keys proves +// only their paths, and the subtrees it leaves out are opaque hashes the root +// still has to come out of. +func TestPBinWitnessDecodeBlindedChild(t *testing.T) { + t.Parallel() + + ms, parentRoot := pbinWitnessCommitted(t, pbinWitnessCorpus()) + pending := pbinWitnessPending() + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), pending.plainKeys, pending.updates) + nodes, _, root := pbinWitnessesOf(t, ms, upd, false) + require.Equal(t, parentRoot, root) + + w := pbinWitnessDecoded(t, nodes, root) + _, blinded := pbinWitnessReachable(w) + require.NotEmpty(t, blinded, "the witness resolves every child, so it proves nothing about blinding") + + require.Equal(t, root, pbinWitnessMerkelized(t, nodes, root)) +} + +// TestPBinWitnessDecodePermutationIndependence: the captured set depends on the +// key/value set, not on the order the keys were folded in, and the root it +// re-merkelizes to is the reference implementation's. +func TestPBinWitnessDecodePermutationIndependence(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + forward := make([]int, len(corpus.plainKeys)) + for i := range forward { + forward[i] = i + } + reversed := slices.Clone(forward) + slices.Reverse(reversed) + interleaved := slices.Concat(forward[len(forward)/2:], forward[:len(forward)/2]) + + var tree pbinOracleTree + for _, e := range corpus.entries(t) { + tree.insert(e.key, e.value) + } + want := pbinOracleMerkelizeWith(tree.root, nil) + + for name, order := range map[string][]int{ + "forward": forward, + "reversed": reversed, + "interleaved": interleaved, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + nodes, root := pbinWitnessCapture(t, corpus.permute(order)) + require.Equal(t, want[:], pbinWitnessMerkelized(t, nodes, root)) + }) + } +} + +// TestPBinWitnessDecodeIgnoresNodeOrder: the witness an RPC consumer receives is +// sorted by node bytes, so the decode may not require the root to lead the slice +// — it is told the root and looks it up. +func TestPBinWitnessDecodeIgnoresNodeOrder(t *testing.T) { + t.Parallel() + + nodes, root := pbinWitnessCapture(t, pbinWitnessCorpus()) + require.Greater(t, len(nodes), 1) + + sorted := slices.Clone(nodes) + slices.SortFunc(sorted, bytes.Compare) + require.NotEqual(t, nodes[0], sorted[0], "the sort has to move the root off the front") + require.Equal(t, root, pbinWitnessMerkelized(t, sorted, root)) + + reversed := slices.Clone(nodes) + slices.Reverse(reversed) + require.Equal(t, root, pbinWitnessMerkelized(t, reversed, root)) +} + +// TestPBinWitnessDecodeSingleNodeRemoval: dropping a node blinds its subtree, +// which leaves the root alone. The one drop that could change it — the root +// node's — has to be caught instead. +func TestPBinWitnessDecodeSingleNodeRemoval(t *testing.T) { + t.Parallel() + + nodes, root := pbinWitnessCapture(t, pbinWitnessCorpus()) + require.Greater(t, len(nodes), 1) + + rejected := 0 + for i := range nodes { + short := make([][]byte, 0, len(nodes)-1) + short = append(short, nodes[:i]...) + short = append(short, nodes[i+1:]...) + + w, err := pbinDecodeWitness(short, root) + if err != nil { + rejected++ + continue + } + got, err := w.merkelize() + if err != nil { + rejected++ + continue + } + require.Equal(t, root, got[:], "dropping node %d moved the root instead of failing", i) + } + require.Equal(t, 1, rejected, "only the root node's removal is unrecoverable") +} + +// TestPBinWitnessDecodeEmptyTree: an empty tree is 32 zero bytes with no node +// behind it (eip:"Node merkelization"), and a witness claiming any other root with no nodes is +// unusable rather than empty. +func TestPBinWitnessDecodeEmptyTree(t *testing.T) { + t.Parallel() + + w, err := pbinDecodeWitness(nil, pbinEmptyTreeHash[:]) + require.NoError(t, err) + got, err := w.merkelize() + require.NoError(t, err) + require.Equal(t, pbinEmptyTreeHash, got) + + nonEmpty := common.Hash{0x01} + _, err = pbinDecodeWitness(nil, nonEmpty[:]) + require.ErrorIs(t, err, errPBinWitnessNode) + + _, err = pbinDecodeWitness(nil, nil) + require.ErrorIs(t, err, errPBinWitnessNode) +} diff --git a/execution/commitment/pbin_witness_prune.go b/execution/commitment/pbin_witness_prune.go new file mode 100644 index 00000000000..6cb7ebb4a30 --- /dev/null +++ b/execution/commitment/pbin_witness_prune.go @@ -0,0 +1,118 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "fmt" + + "github.com/erigontech/erigon/common" +) + +// Pruning the captured superset down to the proof paths of the keys the fold +// walked — the binary analogue of trie.WitnessNodesForKeysFromNodes. + +// PBinWitnessNodesForKeys keeps the nodes on the proof path of every proved key, +// plus the sibling hanging off each branch along it, and drops the rest, +// returning them in walk order so the root leads. A path that runs into a leaf, +// a diverging branch prefix or a blinded child stops there — what it walked +// through is the proof that the key is absent. +func PBinWitnessNodesForKeys(nodes [][]byte, root []byte, provedKeys [][]byte) ([][]byte, error) { + if len(nodes) == 0 { + return nil, nil + } + tree, err := pbinDecodeWitness(nodes, root) + if err != nil { + return nil, err + } + p := pbinWitnessPruner{tree: tree, kept: make(map[common.Hash]struct{}, len(tree.nodes))} + // The root node leads the output even when no key descends past it. + p.keep(tree.root) + for _, key := range provedKeys { + if err := p.walk(key); err != nil { + return nil, err + } + } + out := make([][]byte, 0, len(p.order)) + for _, hash := range p.order { + out = append(out, tree.nodes[hash].preimage) + } + return out, nil +} + +type pbinWitnessPruner struct { + tree *pbinWitnessTree + kept map[common.Hash]struct{} + order []common.Hash +} + +func (p *pbinWitnessPruner) keep(hash common.Hash) { + if _, seen := p.kept[hash]; seen { + return + } + p.kept[hash] = struct{}{} + p.order = append(p.order, hash) +} + +// keepSibling keeps the child the walk turns away from. Its hash is committed by +// the branch above it either way; what the preimage adds is the ability to +// re-hash it under a longer prefix, which is what a removal on the other side of +// the branch makes the consumer do. A sibling the capture blinded is skipped — +// then the consumer can still read the branch, just not delete under it. +func (p *pbinWitnessPruner) keepSibling(hash common.Hash) { + if _, ok := p.tree.nodes[hash]; ok { + p.keep(hash) + } +} + +func (p *pbinWitnessPruner) walk(key []byte) error { + path, err := pbinWitnessProvedPath(key) + if err != nil { + return err + } + hash, pos := p.tree.root, int16(0) + for { + node, ok := p.tree.nodes[hash] + if !ok { + return nil + } + p.keep(hash) + if node.isLeaf() { + return nil + } + end := pos + node.prefix.bitLen + if end >= path.bitLen || pbinCommonPrefixBitsAt(&path, pos, &node.prefix) != node.prefix.bitLen { + return nil + } + bit := path.bit(end) + p.keepSibling(node.children[1-bit]) + hash, pos = node.children[bit], end+1 + } +} + +// pbinWitnessProvedPath rejects a key no zone admits rather than letting +// pbinPathFromBytes panic on it. A key shorter than its zone's length is a +// subtree prefix, which an account removal proves in place of the leaves it +// drops, so the walk stops where that subtree begins. +func pbinWitnessProvedPath(key []byte) (pbinBitpath, error) { + if len(key) == 0 { + return pbinBitpath{}, fmt.Errorf("%w: empty proved key", errPBinWitnessNode) + } + if want, known := pbinZoneKeyLength(key[0]); !known || len(key) > want { + return pbinBitpath{}, fmt.Errorf("%w: proved key %x is no key of zone %#x", errPBinWitnessNode, key, key[0]) + } + return pbinPathFromBytes(key), nil +} diff --git a/execution/commitment/pbin_witness_prune_test.go b/execution/commitment/pbin_witness_prune_test.go new file mode 100644 index 00000000000..11a8c9bcae4 --- /dev/null +++ b/execution/commitment/pbin_witness_prune_test.go @@ -0,0 +1,367 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "slices" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +type pbinWitnessPruneFixture struct { + state *MockState + pending *pbinTestCorpus + nodes [][]byte + provedKeys [][]byte + root []byte + tree *pbinWitnessTree +} + +// pbinWitnessPruneSetup commits a corpus and captures the superset witness of +// the pending updates against it — the input the pruner has to cut down. +func pbinWitnessPruneSetup(t *testing.T) *pbinWitnessPruneFixture { + t.Helper() + f := &pbinWitnessPruneFixture{pending: pbinWitnessContextPending()} + f.state, f.root = pbinWitnessCommitted(t, pbinWitnessCorpus()) + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), f.pending.plainKeys, f.pending.updates) + nodes, provedKeys, root := pbinWitnessesOf(t, f.state, upd, false) + require.Equal(t, f.root, root) + f.nodes, f.provedKeys = nodes, provedKeys + f.tree = pbinWitnessDecoded(t, f.nodes, f.root) + return f +} + +func (f *pbinWitnessPruneFixture) prune(t *testing.T, provedKeys [][]byte) [][]byte { + t.Helper() + lean, err := PBinWitnessNodesForKeys(f.nodes, f.root, provedKeys) + require.NoError(t, err) + return lean +} + +// postStateRoot applies the pending updates over a node set through the witness +// context, which is what a pruned witness still has to support. +func (f *pbinWitnessPruneFixture) postStateRoot(t *testing.T, nodes [][]byte) []byte { + t.Helper() + witness := pbinNewWitnessContext(pbinWitnessDecoded(t, nodes, f.root)) + for addr, code := range f.pending.codes { + witness.setCode([]byte(addr), code) + } + return f.applyOver(t, witness) +} + +func (f *pbinWitnessPruneFixture) applyOver(t *testing.T, ctx PatriciaContext) []byte { + t.Helper() + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), f.pending.plainKeys, f.pending.updates) + root, err := NewPBinPatriciaHashed(ctx).Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.NoError(t, err) + return bytes.Clone(root) +} + +func pbinWitnessHashSet(t *testing.T, nodes [][]byte) map[common.Hash]struct{} { + t.Helper() + h := pbinHasher{sum: pbinSelectedSum} + out := make(map[common.Hash]struct{}, len(nodes)) + for _, node := range nodes { + out[h.hash(node)] = struct{}{} + } + return out +} + +// pbinWitnessOnPathNodes names the nodes the proved keys walk through and the +// sibling hanging off each branch they descend, stated as "the path taken to +// reach the node is a prefix of some proved key, or its parent's is" over a walk +// of the whole tree — not as the per-key descent the pruner runs. +func pbinWitnessOnPathNodes(w *pbinWitnessTree, provedKeys [][]byte) map[common.Hash]struct{} { + paths := make([]pbinBitpath, 0, len(provedKeys)) + for _, key := range provedKeys { + paths = append(paths, pbinPathFromBytes(key)) + } + onPath := func(arrival *pbinBitpath) bool { + for i := range paths { + if paths[i].hasPrefix(arrival) { + return true + } + } + return false + } + out := make(map[common.Hash]struct{}) + keep := func(hash common.Hash) { + if _, ok := w.nodes[hash]; ok { + out[hash] = struct{}{} + } + } + var walk func(hash common.Hash, arrival pbinBitpath) + walk = func(hash common.Hash, arrival pbinBitpath) { + node, ok := w.nodes[hash] + if !ok || !onPath(&arrival) { + return + } + out[hash] = struct{}{} + if node.isLeaf() { + return + } + child := [2]pbinBitpath{} + for bit := range node.children { + child[bit] = arrival + child[bit].append(&node.prefix) + child[bit].appendBit(uint64(bit)) + } + for bit := range node.children { + if onPath(&child[bit]) { + keep(node.children[1-bit]) + } + walk(node.children[bit], child[bit]) + } + } + walk(w.root, pbinBitpath{}) + return out +} + +// TestPBinWitnessPruneKeepsProofPaths: the lean set has to be a witness in its +// own right — it re-merkelizes to the same root and still carries the block's +// updates to the same post-state root the full capture does. +func TestPBinWitnessPruneKeepsProofPaths(t *testing.T) { + t.Parallel() + + f := pbinWitnessPruneSetup(t) + lean := f.prune(t, f.provedKeys) + + require.NotEmpty(t, lean) + require.Equal(t, f.nodes[0], lean[0], "root node is not first") + require.Equal(t, f.root, pbinWitnessMerkelized(t, lean, f.root)) + + require.Equal(t, f.postStateRoot(t, f.nodes), f.postStateRoot(t, lean)) + require.Equal(t, f.applyOver(t, f.state), f.postStateRoot(t, lean)) +} + +// TestPBinWitnessPruneDropsOffPathNodes: the capture holds nodes neither a proved +// key nor a collapse reaches — whole subtrees hanging two or more levels off a +// path. +func TestPBinWitnessPruneDropsOffPathNodes(t *testing.T) { + t.Parallel() + + f := pbinWitnessPruneSetup(t) + lean := f.prune(t, f.provedKeys) + + full := pbinWitnessHashSet(t, f.nodes) + kept := pbinWitnessHashSet(t, lean) + require.Less(t, len(lean), len(f.nodes), "nothing was pruned, so the test proves nothing") + for hash := range kept { + require.Contains(t, full, hash, "the pruned set invented node %x", hash) + } + require.Equal(t, pbinWitnessOnPathNodes(f.tree, f.provedKeys), kept) +} + +// TestPBinWitnessPruneKeepsCodeLeaves: a contract's code leaves are proved keys +// of their own (they never reach HashSort), and a pruner walking only the account +// key would drop the code the post-state pass then cannot chunk. +func TestPBinWitnessPruneKeepsCodeLeaves(t *testing.T) { + t.Parallel() + + f := pbinWitnessPruneSetup(t) + lean := f.prune(t, f.provedKeys) + + addr := pbinOracleAddr(21) + code := pbinWitnessContextCode() + chunks := pbinChunkifyCode(code) + require.Greater(t, len(chunks), 1) + + leaves := make(map[string]struct{}) + for _, node := range lean { + decoded, err := pbinDecodeWitnessNode(node) + require.NoError(t, err) + if decoded.isLeaf() { + leaves[string(decoded.key)] = struct{}{} + } + } + for i := range chunks { + require.Contains(t, leaves, string(pbinTreeKeyCodeChunk(keccak.Sum256(code), i)), "code chunk %d was pruned away", i) + } + require.Contains(t, leaves, string(pbinTreeKeyAccount(addr, pbinCodeHashLeafKey))) +} + +// TestPBinWitnessPruneStopsAtBlindedChild: a key whose path leaves the witness +// keeps what it walked and stops. +func TestPBinWitnessPruneStopsAtBlindedChild(t *testing.T) { + t.Parallel() + + f := pbinWitnessPruneSetup(t) + blind := pbinWitnessKeyThrough(t, pbinWitnessBlindedPath(t, f.tree)) + require.NotContains(t, pbinWitnessKeySet(f.provedKeys), string(blind)) + + lean := f.prune(t, [][]byte{blind}) + require.Greater(t, len(lean), 1, "the walk stopped at the root, so it never reached the blinded child") + require.Equal(t, f.root, pbinWitnessMerkelized(t, lean, f.root)) + require.Equal(t, pbinWitnessOnPathNodes(f.tree, [][]byte{blind}), pbinWitnessHashSet(t, lean)) + + both := f.prune(t, append(slices.Clone(f.provedKeys), blind)) + require.Equal(t, f.root, pbinWitnessMerkelized(t, both, f.root)) + require.Equal(t, f.postStateRoot(t, f.nodes), f.postStateRoot(t, both)) +} + +// pbinWitnessKeyThrough builds the tree key of the zone path leads into, so a +// walk of that key descends exactly the path. +func pbinWitnessKeyThrough(t *testing.T, path pbinBitpath) []byte { + t.Helper() + key := path.appendPackedBits(nil) + require.NotEmpty(t, key) + want, known := pbinZoneKeyLength(key[0]) + require.True(t, known, "path %x leads into no allocated zone", key) + require.LessOrEqual(t, len(key), want) + return append(key, make([]byte, want-len(key))...) +} + +func pbinWitnessKeySet(keys [][]byte) map[string]struct{} { + out := make(map[string]struct{}, len(keys)) + for _, key := range keys { + out[string(key)] = struct{}{} + } + return out +} + +// TestPBinWitnessPruneRejectsMalformedKey: a proved key of no zone would panic in +// the bit-path conversion, which an RPC handler must not do. +func TestPBinWitnessPruneRejectsMalformedKey(t *testing.T) { + t.Parallel() + + f := pbinWitnessPruneSetup(t) + for _, tc := range []struct { + name string + key []byte + }{ + {name: "empty key", key: nil}, + {name: "unallocated zone", key: bytes.Repeat([]byte{0x02}, pbinAccountKeyLength)}, + {name: "wrong length for its zone", key: make([]byte, pbinAccountKeyLength+1)}, + {name: "longer than the path", key: make([]byte, 2*pbinStorageKeyLength)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := PBinWitnessNodesForKeys(f.nodes, f.root, [][]byte{tc.key}) + require.ErrorIs(t, err, errPBinWitnessNode) + }) + } +} + +// TestPBinWitnessPruneKeepsSubtreePrefix: an account removal proves the subtree +// it drops, not the leaves inside it, so a key shorter than its zone's length is +// a proved key and the walk stops where that subtree begins. +func TestPBinWitnessPruneKeepsSubtreePrefix(t *testing.T) { + t.Parallel() + + f := pbinWitnessPruneSetup(t) + var leafKey []byte + for _, key := range f.provedKeys { + if len(key) == pbinAccountKeyLength && key[0] == pbinAccountZone { + leafKey = key + break + } + } + require.NotEmpty(t, leafKey, "the capture proved no account-zone leaf") + stem := leafKey[:pbinAccountKeyLength-1] + + kept := pbinWitnessHashSet(t, f.prune(t, [][]byte{stem})) + require.NotEmpty(t, kept) + for hash := range kept { + require.Contains(t, pbinWitnessHashSet(t, f.prune(t, [][]byte{leafKey})), hash, + "the stem walk descended past the subtree the leaf key reaches") + } + require.Equal(t, f.root, pbinWitnessMerkelized(t, f.prune(t, [][]byte{stem}), f.root)) +} + +// TestPBinWitnessServesRemoval: collapsing a branch re-hashes the surviving +// sibling under a longer prefix, which needs its own preimage — a branch hash +// commits to the prefix it had and can't be reused as-is. Both sibling shapes +// are covered: a leaf the fold already hashes, and a branch that arrives as a +// bare hash from its parent's record. +func TestPBinWitnessServesRemoval(t *testing.T) { + t.Parallel() + + addr, bystander := pbinOracleAddr(41), pbinOracleAddr(42) + // Storage-zone sub-indices split on the low bits of the slot: 0 and 1 sit + // under one branch, 2 under the other. + stored := func(slots ...uint64) *pbinTestCorpus { + c := new(pbinTestCorpus).account(bystander, 1, 2, common.Hash{0x42}) + for _, slot := range slots { + c.storage(addr, pbinOracleSlot(slot), 0x01) + } + return c + } + for _, tc := range []struct { + name string + stored, survivors *pbinTestCorpus + gone uint64 + }{ + { + name: "collapse onto a leaf sibling", + stored: stored(256, 257, 258, 259), + survivors: stored(257, 258, 259), + gone: 256, + }, + { + name: "collapse onto a branch sibling", + stored: stored(256, 257, 258), + survivors: stored(256, 257), + gone: 258, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ms, root := pbinWitnessCommitted(t, tc.stored) + zeroed := new(pbinTestCorpus).storage(addr, pbinOracleSlot(tc.gone)) + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), zeroed.plainKeys, zeroed.updates) + nodes, provedKeys, captured := pbinWitnessesOf(t, ms, upd, false) + require.Equal(t, root, captured) + + lean, err := PBinWitnessNodesForKeys(nodes, root, provedKeys) + require.NoError(t, err) + require.Less(t, len(lean), len(nodes), "nothing was pruned, so the test proves nothing") + + want := tc.survivors.oracleRoot(t) + require.NotEqual(t, root, want, "the removal did not move the root") + for _, set := range []struct { + name string + nodes [][]byte + }{{"superset", nodes}, {"lean", lean}} { + witness := pbinNewWitnessContext(pbinWitnessDecoded(t, set.nodes, root)) + got, err := NewPBinPatriciaHashed(witness).Process(context.Background(), + WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), zeroed.plainKeys, zeroed.updates), + "", nil, WarmupConfig{}) + require.NoError(t, err, "%s cannot serve the removal", set.name) + require.Equal(t, want, got, "%s reached the wrong post-state root", set.name) + } + }) + } +} + +// TestPBinWitnessPruneEmptyCapture: an update set that touches nothing produces +// no nodes to prune. +func TestPBinWitnessPruneEmptyCapture(t *testing.T) { + t.Parallel() + + lean, err := PBinWitnessNodesForKeys(nil, pbinEmptyTreeHash[:], nil) + require.NoError(t, err) + require.Empty(t, lean) +} diff --git a/execution/commitment/pbin_witness_state.go b/execution/commitment/pbin_witness_state.go new file mode 100644 index 00000000000..d214b8f1992 --- /dev/null +++ b/execution/commitment/pbin_witness_state.go @@ -0,0 +1,310 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + + keccak "github.com/erigontech/fastkeccak" + "github.com/holiman/uint256" + + "github.com/erigontech/erigon/common" +) + +// A decoded witness read as pre-state. pbinWitnessContext already serves it to +// the engine by bit path; this reads it the way a stateless verifier does, by +// address and slot, and drives the engine over it for the post-state root. +// +// Resolution is strict and not optional: a hash the witness carries no preimage +// for is an error on every path, never an empty read. Hex makes that a +// WITNESS_STRICT_VERIFY opt-in because an MPT witness can be legitimately +// incomplete; under bin an unresolved hash is unambiguous, so there is no mode +// where guessing is right. + +// PBinAccount is the account state the header leaves hold. A delegated account +// has no CODE_HASH leaf, and its CodeHash is the keccak of the indicator bytes +// its DELEGATION leaf carries. The binary tree commits no per-account storage +// root, so there is no field for one. +type PBinAccount struct { + Nonce uint64 + Balance uint256.Int + CodeSize uint64 + CodeHash common.Hash +} + +type PBinWitnessState struct { + tree *pbinWitnessTree + ctx *pbinWitnessContext + keys pbinDigestCache +} + +func PBinNewWitnessState(nodes [][]byte, root []byte) (*PBinWitnessState, error) { + tree, err := pbinDecodeWitness(nodes, root) + if err != nil { + return nil, err + } + return &PBinWitnessState{ + tree: tree, + ctx: pbinNewWitnessContext(tree), + keys: pbinDigestCache{sum: pbinSelectedSum}, + }, nil +} + +// SetCode supplies bytecode the witness cannot hold: code a block deploys has no +// pre-state chunk leaves. Everything else is read from the leaves. +func (s *PBinWitnessState) SetCode(addr, code []byte) { s.ctx.setCode(addr, code) } + +func (s *PBinWitnessState) Account(addr []byte) (PBinAccount, bool, error) { + // An account holds exactly one of the CODE_HASH and DELEGATION leaves, and + // neither is ever zero, so whichever exists marks the account present — + // while BASIC_DATA is absent for an account whose nonce, balance and + // code_size are all zero. + var acc PBinAccount + basic, hasBasic, err := s.tree.leaf(s.keys.accountKey(addr, pbinBasicDataLeafKey)) + if err != nil { + return PBinAccount{}, false, err + } + if hasBasic { + acc.CodeSize = uint64(binary.BigEndian.Uint32(basic[pbinBasicDataCodeSizeOffset:])) + acc.Nonce = binary.BigEndian.Uint64(basic[pbinBasicDataNonceOffset:]) + acc.Balance.SetBytes(basic[pbinBasicDataBalanceOffset:]) + } + + codeHash, ok, err := s.tree.leaf(s.keys.accountKey(addr, pbinCodeHashLeafKey)) + if err != nil { + return PBinAccount{}, false, err + } + if ok { + acc.CodeHash = common.BytesToHash(codeHash) + return acc, true, nil + } + + indicator, err := s.ctx.delegationCode(addr, acc.CodeSize) + if err != nil { + return PBinAccount{}, false, err + } + if indicator == nil { + if hasBasic { + return PBinAccount{}, false, fmt.Errorf("%w: account %x has a BASIC_DATA leaf but neither a CODE_HASH nor a DELEGATION leaf", + errPBinWitnessNode, addr) + } + return PBinAccount{}, false, nil + } + // A delegated account commits no code hash; EXTCODEHASH defines its hash as + // the keccak of the indicator bytes. + acc.CodeHash = common.Hash(keccak.Sum256(indicator)) + return acc, true, nil +} + +// An absent slot resolves to the zero hash, matching SLOAD's default value. +func (s *PBinWitnessState) Storage(addr, slot []byte) (common.Hash, bool, error) { + value, ok, err := s.tree.leaf(s.keys.storageKey(addr, slot)) + if err != nil || !ok { + return common.Hash{}, false, err + } + return common.BytesToHash(value), true, nil +} + +// HasStorage reports whether the witness holds a non-zero storage slot of the +// account — EIP-7610's CREATE-collision predicate. The tree commits no +// per-account storage root, so the answer is read from the two key regions an +// account owns. The header slots resolve off the same proof path the account's +// own leaves sit on; the storage zone needs a key of its own walked into it, so +// a witness whose keys never enter the zone reads it as empty. +func (s *PBinWitnessState) HasStorage(addr []byte) bool { + // Sub-indices 64..127 are the header's storage slots, which is exactly the + // header stem extended by the two bits pbinHeaderStorageOffset leads with. + stem := s.keys.accountHeaderStem(addr) + header := pbinPathFromBits(append(stem, pbinHeaderStorageOffset), int16(8*len(stem)+2)) + if s.tree.hasSubtree(&header) { + return true + } + zone := pbinPathFromBytes(s.keys.accountStoragePrefix(addr)) + return s.tree.hasSubtree(&zone) +} + +// Code returns the account's bytecode: reassembled from the chunk leaves, or +// read from the DELEGATION leaf for a delegated account. +func (s *PBinWitnessState) Code(addr []byte) ([]byte, bool, error) { + code, err := s.ctx.codeFromLeaves(addr) + if err != nil { + return nil, false, err + } + return code, code != nil, nil +} + +// Root applies the block's writes over the witness and returns the post-state +// root. +func (s *PBinWitnessState) Root(ctx context.Context, plainKeys [][]byte, updates []Update) ([]byte, error) { + if len(plainKeys) != len(updates) { + return nil, fmt.Errorf("pbin: %d plain keys for %d updates", len(plainKeys), len(updates)) + } + trie := NewPBinPatriciaHashed(s.ctx) + defer trie.Release() + + upd := NewUpdates(ModeUpdate, "", trie.setHashSuite(pbinSelectedSum)) + for i := range plainKeys { + upd.TouchPlainKeyDirect(string(plainKeys[i]), &updates[i]) + } + root, err := trie.Process(ctx, upd, "pbin-witness", nil, WarmupConfig{}) + if err != nil { + return nil, err + } + return bytes.Clone(root), nil +} + +// codeFromLeaves is the witness's own answer to "what code does this account +// run". The leaves are the single code source under bin: they are committed by +// the root, and the fold re-chunks every account it touches, so the pruned +// witness carries a chunk leaf wherever the post-state pass needs one. The +// reassembly is checked against the CODE_HASH leaf, so it cannot drift from the +// chunker. A nil result means the witness proves the account absent. +func (c *pbinWitnessContext) codeFromLeaves(addr []byte) ([]byte, error) { + // An account whose nonce, balance and code_size are all zero stores no + // BASIC_DATA leaf, so its absence is zeros rather than an absent account — + // the CODE_HASH or DELEGATION leaf is what marks the account present. + hashValue, hasCodeHash, err := c.tree.leaf(c.keys.accountKey(addr, pbinCodeHashLeafKey)) + if err != nil { + return nil, err + } + + var size uint64 + if basic, ok, err := c.tree.leaf(c.keys.accountKey(addr, pbinBasicDataLeafKey)); err != nil { + return nil, err + } else if ok { + size = uint64(binary.BigEndian.Uint32(basic[pbinBasicDataCodeSizeOffset:])) + } + + if !hasCodeHash { + return c.delegationCode(addr, size) + } + codeHash := common.BytesToHash(hashValue) + if size == 0 { + return []byte{}, nil + } + + code := make([]byte, 0, size) + for chunk := 0; chunk < pbinCodeChunkCount(size); chunk++ { + value, ok, err := c.tree.leaf(c.keys.codeChunkKey(codeHash, chunk)) + if err != nil { + return nil, err + } + if !ok { + // A chunk of 31 zero bytes is stored as no leaf at all, so an absent + // chunk is the zeros it stands for. code_size delimits the code, not + // which chunks are present. + var zero [pbinValueLength]byte + value = zero[:] + } + code = append(code, value[1:]...) + } + code = code[:size] + if got := common.Hash(keccak.Sum256(code)); got != codeHash { + return nil, fmt.Errorf("%w: code of account %x reassembles to %x, the CODE_HASH leaf says %x", + errPBinWitnessNode, addr, got, codeHash) + } + return code, nil +} + +// delegationCode reads a delegated account's code: the indicator its DELEGATION +// leaf carries. There is nothing to reassemble and no hash to check against — +// the root commits the leaf itself — so the leaf's fixed shape is the only thing +// that can be checked, and code_size has to agree with it. +func (c *pbinWitnessContext) delegationCode(addr []byte, size uint64) ([]byte, error) { + value, ok, err := c.tree.leaf(c.keys.accountKey(addr, pbinDelegationLeafKey)) + if err != nil || !ok { + return nil, err + } + if size != pbinDelegationCodeLength || len(value) < pbinDelegationCodeLength { + return nil, fmt.Errorf("%w: delegation leaf of account %x holds %d bytes under code_size %d, want %d bytes of indicator", + errPBinWitnessNode, addr, len(value), size, pbinDelegationCodeLength) + } + return bytes.Clone(value[:pbinDelegationCodeLength]), nil +} + +func pbinCodeChunkCount(size uint64) int { + return int((size + pbinChunkDataLen - 1) / pbinChunkDataLen) +} + +// hasSubtree reports whether the witness proves a leaf exists under prefix. +// Unlike leaf, an unresolved hash is not an error here: a pruned witness proves +// only the regions its keys walked, so this answers what the node set can see +// and leaves the rest to read as empty. +func (w *pbinWitnessTree) hasSubtree(prefix *pbinBitpath) bool { + hash, pos := w.root, int16(0) + for { + if hash == pbinEmptyTreeHash { + return false + } + if pos >= prefix.bitLen { + return true + } + node, ok := w.nodes[hash] + if !ok { + return false + } + if node.isLeaf() { + key := pbinPathFromBytes(node.key) + return key.hasPrefix(prefix) + } + limit := min(prefix.bitLen-pos, node.prefix.bitLen) + if pbinCommonPrefixBitsAt(prefix, pos, &node.prefix) != limit { + return false + } + if prefix.bitLen-pos <= node.prefix.bitLen { + return true + } + end := pos + node.prefix.bitLen + hash, pos = node.children[prefix.bit(end)], end+1 + } +} + +// leaf resolves the value at a tree key. found is false when the walk reaches a +// node that proves the key absent — a leaf of another key, or a branch prefix +// the key diverges from. A hash the set carries no preimage for is an error, so +// an unresolved subtree is never read as an absent key. +func (w *pbinWitnessTree) leaf(key []byte) ([]byte, bool, error) { + path, err := pbinWitnessProvedPath(key) + if err != nil { + return nil, false, err + } + hash, pos := w.root, int16(0) + if hash == pbinEmptyTreeHash { + return nil, false, nil + } + for { + node, ok := w.nodes[hash] + if !ok { + return nil, false, fmt.Errorf("%w: no preimage for %x, reached at bit %d of key %x", + errPBinWitnessBlinded, hash, pos, key) + } + if node.isLeaf() { + if !bytes.Equal(node.key, key) { + return nil, false, nil + } + return node.value, true, nil + } + end := pos + node.prefix.bitLen + if end >= path.bitLen || pbinCommonPrefixBitsAt(&path, pos, &node.prefix) != node.prefix.bitLen { + return nil, false, nil + } + hash, pos = node.children[path.bit(end)], end+1 + } +} diff --git a/execution/commitment/pbin_witness_test.go b/execution/commitment/pbin_witness_test.go new file mode 100644 index 00000000000..51cb23cfe99 --- /dev/null +++ b/execution/commitment/pbin_witness_test.go @@ -0,0 +1,388 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "maps" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// pbinWitnessRecorder keeps every emission in arrival order, so a node hashed +// more than once stays visible instead of being folded away. +type pbinWitnessRecorder struct { + preimages [][]byte + hashes [][]byte +} + +func (r *pbinWitnessRecorder) onNode(preimage, hash []byte) { + r.preimages = append(r.preimages, bytes.Clone(preimage)) + r.hashes = append(r.hashes, bytes.Clone(hash)) +} + +// byHash folds the emissions into the node set a witness carries and checks the +// property that set relies on: one hash, one preimage. +func (r *pbinWitnessRecorder) byHash(t *testing.T) map[string][]byte { + t.Helper() + out := make(map[string][]byte, len(r.hashes)) + for i, hash := range r.hashes { + if prev, seen := out[string(hash)]; seen { + require.Equal(t, prev, r.preimages[i], "hash %x emitted with two preimages", hash) + continue + } + out[string(hash)] = r.preimages[i] + } + return out +} + +// pbinWitnessRejectingTracer fails the test on any emission; it stands in for a +// tracer that must have been detached. +type pbinWitnessRejectingTracer struct{ t *testing.T } + +func (r *pbinWitnessRejectingTracer) onNode(preimage, hash []byte) { + r.t.Helper() + r.t.Fatalf("detached tracer received node %x", hash) +} + +// pbinWitnessOracleNodes enumerates the reference tree's nodes as +// preimage-by-hash, derived from the corpus rather than from the engine. +func pbinWitnessOracleNodes(t *testing.T, entries []pbinOracleEntry) map[string][]byte { + t.Helper() + var tree pbinOracleTree + for _, e := range entries { + tree.insert(e.key, e.value) + } + out := make(map[string][]byte) + pbinWitnessCollectOracleNodes(t, tree.root, out) + return out +} + +func pbinWitnessCollectOracleNodes(t *testing.T, node pbinOracleNode, out map[string][]byte) []byte { + t.Helper() + if node == nil { + return make([]byte, length.Hash) + } + var preimage []byte + switch n := node.(type) { + case *pbinOracleLeaf: + preimage = append(preimage, pbinOracleLeafTag) + preimage = append(preimage, n.key...) + preimage = append(preimage, n.value...) + case *pbinOracleBranch: + left := pbinWitnessCollectOracleNodes(t, n.left, out) + right := pbinWitnessCollectOracleNodes(t, n.right, out) + preimage = append(preimage, pbinOracleBranchTag) + preimage = append(preimage, pbinOracleEncodeBitPrefix(n.prefix)...) + preimage = append(preimage, left...) + preimage = append(preimage, right...) + default: + t.Fatalf("unknown oracle node %T", node) + } + hash := pbinTestKeccak(t, preimage) + out[string(hash)] = preimage + return hash +} + +// pbinWitnessCorpus spans both zones and carries code, so the emitted set holds +// BASIC_DATA, CODE_HASH, code-chunk and storage leaves. +func pbinWitnessCorpus() *pbinTestCorpus { + c := new(pbinTestCorpus) + c.accountWithCodeBytes(pbinOracleAddr(21), 1, 500, bytes.Repeat([]byte{0x60}, 200)) + c.account(pbinOracleAddr(22), 2, 900, common.Hash{0x22}) + for _, slot := range []uint64{0, 63, 64, 256, 1 << 20} { + c.storage(pbinOracleAddr(21), pbinOracleSlot(slot), 0x11) + c.storage(pbinOracleAddr(22), pbinOracleSlot(slot), 0x22) + } + return c +} + +func pbinWitnessProcess(t *testing.T, corpus *pbinTestCorpus, tracer witnessTracer) ([]byte, *PBinPatriciaHashed) { + t.Helper() + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + pph.setWitnessTracer(tracer) + return pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates), pph +} + +// TestPBinWitnessTracerNilEmitsNothing: the tap is inert without a tracer, and +// detaching one really detaches it. +func TestPBinWitnessTracerNilEmitsNothing(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + + pph.setWitnessTracer(&pbinWitnessRejectingTracer{t: t}) + pph.setWitnessTracer(nil) + + root := pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates) + require.Equal(t, corpus.oracleRoot(t), root) +} + +// TestPBinWitnessTracerEmitsEveryNode: a traced fold yields every node of the +// tree it builds, each one hashing to the hash it was emitted with, and the +// root is unchanged by the tracing. +func TestPBinWitnessTracerEmitsEveryNode(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + untracedRoot, _ := pbinWitnessProcess(t, corpus, nil) + + rec := new(pbinWitnessRecorder) + root, _ := pbinWitnessProcess(t, corpus, rec) + require.Equal(t, untracedRoot, root) + require.Equal(t, corpus.oracleRoot(t), root) + + tags := map[byte]int{} + for i, preimage := range rec.preimages { + require.NotEmpty(t, preimage) + require.Equal(t, pbinTestKeccak(t, preimage), rec.hashes[i], "emission %d does not hash to its own preimage", i) + tags[preimage[0]]++ + } + require.Positive(t, tags[pbinLeafTag], "no leaf node emitted") + require.Positive(t, tags[pbinBranchTag], "no branch node emitted") + + emitted := rec.byHash(t) + require.Contains(t, emitted, string(root), "root node absent from the emitted set") + for hash, preimage := range pbinWitnessOracleNodes(t, corpus.entries(t)) { + got, ok := emitted[hash] + require.True(t, ok, "node %x of the reference tree was never emitted", hash) + require.Equal(t, preimage, got) + } +} + +// TestPBinWitnessTracerCoversRootLeaf: a one-key tree folds no row, so its only +// node is hashed by RootHash rather than during a fold. +func TestPBinWitnessTracerCoversRootLeaf(t *testing.T) { + t.Parallel() + + addr, slot := pbinOracleAddr(31), pbinOracleSlot(7000) + corpus := new(pbinTestCorpus).storage(addr, slot, 0x01, 0x02) + + rec := new(pbinWitnessRecorder) + root, pph := pbinWitnessProcess(t, corpus, rec) + require.Equal(t, pbinNodeLeaf, pph.grid.root.kind) + + emitted := rec.byHash(t) + require.Len(t, emitted, 1) + require.Contains(t, emitted, string(root)) + require.Equal(t, byte(pbinLeafTag), emitted[string(root)][0]) +} + +// TestPBinWitnessTracerCoversSiblingCells: the two leaves are hashed by +// hashRowCell during the branch fold, the root by RootHash. All three land in +// the emitted set and nothing else does. +func TestPBinWitnessTracerCoversSiblingCells(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(32) + left, right := pbinOracleSlot(256), pbinOracleSlot(257) + corpus := new(pbinTestCorpus).storage(addr, left, 0xAA).storage(addr, right, 0xBB) + + rec := new(pbinWitnessRecorder) + root, pph := pbinWitnessProcess(t, corpus, rec) + require.Equal(t, pbinNodeBranch, pph.grid.root.kind) + + emitted := rec.byHash(t) + require.Contains(t, emitted, string(root)) + require.Equal(t, pbinWitnessOracleNodes(t, corpus.entries(t)), emitted) +} + +// TestPBinWitnessTracerDetachedOnReset: Reset detaches the tracer, so the +// process that follows would trip a still-attached rejecting one. +func TestPBinWitnessTracerDetachedOnReset(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + + pph.setWitnessTracer(&pbinWitnessRejectingTracer{t: t}) + pph.Reset() + require.Nil(t, pph.hasher.tracer) + + require.Equal(t, corpus.oracleRoot(t), pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates)) +} + +// pbinWitnessCommitted commits the corpus and hands back the state it left +// behind, so a later engine sees a stored tree rather than an empty one. +func pbinWitnessCommitted(t *testing.T, corpus *pbinTestCorpus) (*MockState, []byte) { + t.Helper() + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + return ms, bytes.Clone(pbinTestProcess(t, pph, corpus.plainKeys, corpus.updates)) +} + +func pbinWitnessesOf(t *testing.T, ms *MockState, upd *Updates, produceExclusionProofs bool) (nodes, provedKeys [][]byte, root []byte) { + t.Helper() + nodes, provedKeys, root, err := NewPBinPatriciaHashed(ms).Witnesses(context.Background(), upd, produceExclusionProofs, "") + require.NoError(t, err) + return nodes, provedKeys, root +} + +// pbinWitnessPending is a corpus of updates against pbinWitnessCorpus that no +// state read can produce: applying them moves the root, so a witness pass that +// applied anything would be caught. +func pbinWitnessPending() *pbinTestCorpus { + c := new(pbinTestCorpus) + c.account(pbinOracleAddr(22), 77, 7777, common.Hash{0x99}) + c.account(pbinOracleAddr(23), 3, 300, common.Hash{0x23}) + c.storage(pbinOracleAddr(21), pbinOracleSlot(64), 0xEE) + c.storage(pbinOracleAddr(23), pbinOracleSlot(5), 0x55) + return c +} + +// TestPBinWitnessesReturnsParentRoot: the pass proves the tree as it stands, +// not any pending modifications to it. +func TestPBinWitnessesReturnsParentRoot(t *testing.T) { + t.Parallel() + + ms, parentRoot := pbinWitnessCommitted(t, pbinWitnessCorpus()) + pending := pbinWitnessPending() + + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), pending.plainKeys, pending.updates) + nodes, _, root := pbinWitnessesOf(t, ms, upd, false) + require.Equal(t, parentRoot, root) + require.NotEmpty(t, nodes) + require.Equal(t, nodes[0], pbinWitnessNodeFor(t, nodes, root), "root node is not first") + + applied := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), pending.plainKeys, pending.updates) + postRoot, err := NewPBinPatriciaHashed(ms).Process(context.Background(), applied, "", nil, WarmupConfig{}) + require.NoError(t, err) + require.NotEqual(t, parentRoot, postRoot, "the pending updates do not move the root, so the test proves nothing") +} + +func pbinWitnessNodeFor(t *testing.T, nodes [][]byte, hash []byte) []byte { + t.Helper() + for _, node := range nodes { + if bytes.Equal(pbinTestKeccak(t, node), hash) { + return node + } + } + t.Fatalf("no captured node hashes to %x", hash) + return nil +} + +// TestPBinWitnessesLeavesStateUntouched: the witness pass must not write any +// branch row back to state. +func TestPBinWitnessesLeavesStateUntouched(t *testing.T) { + t.Parallel() + + ms, _ := pbinWitnessCommitted(t, pbinWitnessCorpus()) + before := maps.Clone(ms.cm) + + pending := pbinWitnessPending() + upd := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), pending.plainKeys, pending.updates) + pbinWitnessesOf(t, ms, upd, false) + + require.Equal(t, before, ms.cm) +} + +// TestPBinWitnessesProvesCodeLeaves: one account touch expands into leaves that +// never reach HashSort. Collecting the proved keys there instead of at the emit +// sink would drop every code leaf from the pruned witness. +func TestPBinWitnessesProvesCodeLeaves(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(21) + code := bytes.Repeat([]byte{0x60}, 200) + corpus := pbinWitnessCorpus() + ms, _ := pbinWitnessCommitted(t, corpus) + + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), [][]byte{addr}, []Update{{}}) + _, provedKeys, _ := pbinWitnessesOf(t, ms, upd, false) + + proved := make(map[string]struct{}, len(provedKeys)) + for _, key := range provedKeys { + proved[string(key)] = struct{}{} + } + require.Contains(t, proved, string(pbinTreeKeyAccount(addr, pbinBasicDataLeafKey))) + require.Contains(t, proved, string(pbinTreeKeyAccount(addr, pbinCodeHashLeafKey))) + require.Contains(t, proved, string(pbinTreeKeyAccount(addr, pbinDelegationLeafKey)), + "the unconditional delegation-leaf removal walks its key, so the witness must prove it") + + chunks := pbinChunkifyCode(code) + require.Greater(t, len(chunks), 1) + for i := range chunks { + require.Contains(t, proved, string(pbinTreeKeyCodeChunk(keccak.Sum256(code), i)), "code chunk %d is not proved", i) + } + require.Len(t, provedKeys, 3+len(chunks)) +} + +// TestPBinWitnessesExclusionProofsIgnored: the flag materializes the branch an +// extension node hides, and EIP-8297 has none. Node order is the capture map's, +// so the sets are compared rather than the slices. +func TestPBinWitnessesExclusionProofsIgnored(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + pending := pbinWitnessPending() + + msOff, _ := pbinWitnessCommitted(t, corpus) + off := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), pending.plainKeys, pending.updates) + offNodes, offKeys, offRoot := pbinWitnessesOf(t, msOff, off, false) + + msOn, _ := pbinWitnessCommitted(t, corpus) + on := WrapKeyUpdates(t, ModeUpdate, pbinKeyHasher(), pending.plainKeys, pending.updates) + onNodes, onKeys, onRoot := pbinWitnessesOf(t, msOn, on, true) + + require.Equal(t, offRoot, onRoot) + require.Equal(t, offKeys, onKeys) + require.Equal(t, offNodes[0], onNodes[0]) + require.ElementsMatch(t, offNodes, onNodes) +} + +// TestPBinWitnessesEmptyUpdates: nothing is proved, so nothing is captured, and +// the root still has to come back. +func TestPBinWitnessesEmptyUpdates(t *testing.T) { + t.Parallel() + + ms, parentRoot := pbinWitnessCommitted(t, pbinWitnessCorpus()) + + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), nil, nil) + nodes, provedKeys, root := pbinWitnessesOf(t, ms, upd, false) + require.Equal(t, parentRoot, root) + require.Empty(t, nodes) + require.Empty(t, provedKeys) +} + +// TestPBinWitnessTracerDetachedOnRelease: a pooled engine that kept its tracer +// would leak the next run's nodes into a finished witness. +func TestPBinWitnessTracerDetachedOnRelease(t *testing.T) { + t.Parallel() + + corpus := pbinWitnessCorpus() + pph, ms := pbinTestEngine(t) + corpus.applyTo(t, ms) + + rec := new(pbinWitnessRecorder) + pph.setWitnessTracer(rec) + pph.Release() + + reused := NewPBinPatriciaHashed(ms) + require.Nil(t, reused.hasher.tracer) + require.Equal(t, corpus.oracleRoot(t), pbinTestProcess(t, reused, corpus.plainKeys, corpus.updates)) + require.Empty(t, rec.hashes) +} diff --git a/execution/commitment/pbin_zerovalue_test.go b/execution/commitment/pbin_zerovalue_test.go new file mode 100644 index 00000000000..f7d5ba4c7ad --- /dev/null +++ b/execution/commitment/pbin_zerovalue_test.go @@ -0,0 +1,297 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package commitment + +import ( + "bytes" + "context" + "fmt" + "sort" + "testing" + + keccak "github.com/erigontech/fastkeccak" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/length" +) + +// Zero-vs-absent. EIP-8297 makes them the same state: a leaf whose value is 32 +// zero bytes is not stored, and reads back as the zero it stood for. So the +// domain's shared encoding of the two needs no presence bit, and both a delete +// and a zero write remove the leaf. + +// TestPBinStorageZeroWriteRemovesLeaf covers the update-stream side: the zeroed +// slot is touched, so its leaf is in the grid when the absent read lands. +func TestPBinStorageZeroWriteRemovesLeaf(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + kept, gone uint64 + }{ + {name: "storage zone", kept: 257, gone: 256}, + {name: "account header zone", kept: 6, gone: 5}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(41) + stored := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(tc.gone), 0x01). + storage(addr, pbinOracleSlot(tc.kept), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + before := pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + zeroed := new(pbinTestCorpus).storage(addr, pbinOracleSlot(tc.gone)) + require.NoError(t, ms.applyPlainUpdates(zeroed.plainKeys, []Update{{Flags: DeleteUpdate}})) + + pph.Reset() + root := pbinTestProcess(t, pph, zeroed.plainKeys, zeroed.updates) + + survivorOnly := new(pbinTestCorpus).storage(addr, pbinOracleSlot(tc.kept), 0x02) + require.Equal(t, survivorOnly.oracleRoot(t), root, + "a zeroed slot leaves the tree it would have had without the slot") + require.NotEqual(t, before, root) + }) + } +} + +// TestPBinStorageZeroOnUntouchedSiblingRefuses pins the fold boundary. A slot +// zeroed without being in the update set reaches the fold through its branch +// record, and the only value it could carry is the 32 zero bytes the tree cannot +// hold. Removal lives on the update path, and the grid only walks forward, so +// the fold refuses rather than committing a root no entry set produces. +func TestPBinStorageZeroOnUntouchedSiblingRefuses(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(42) + stored := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + gone := new(pbinTestCorpus).storage(addr, pbinOracleSlot(256)) + require.NoError(t, ms.applyPlainUpdates(gone.plainKeys, []Update{{Flags: DeleteUpdate}})) + + touched := new(pbinTestCorpus).storage(addr, pbinOracleSlot(257), 0x0B) + require.NoError(t, ms.applyPlainUpdates(touched.plainKeys, touched.updates)) + + pph.Reset() + upd := WrapKeyUpdates(t, ModeDirect, pbinKeyHasher(), touched.plainKeys, touched.updates) + _, err := pph.Process(context.Background(), upd, "", nil, WarmupConfig{}) + require.ErrorIs(t, err, errPBinDeleteUnsupported) +} + +// TestPBinStorageZeroOnTouchedSiblingCollapses is the same shape with the +// removal declared: the update path drops the leaf and the root matches the +// entry set without it. +func TestPBinStorageZeroOnTouchedSiblingCollapses(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(42) + stored := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + gone := new(pbinTestCorpus).storage(addr, pbinOracleSlot(256)) + require.NoError(t, ms.applyPlainUpdates(gone.plainKeys, []Update{{Flags: DeleteUpdate}})) + + touched := new(pbinTestCorpus).storage(addr, pbinOracleSlot(257), 0x0B) + require.NoError(t, ms.applyPlainUpdates(touched.plainKeys, touched.updates)) + + both := new(pbinTestCorpus). + storage(addr, pbinOracleSlot(256)). + storage(addr, pbinOracleSlot(257), 0x0B) + both.updates[0] = Update{Flags: DeleteUpdate} + + pph.Reset() + root := pbinTestProcess(t, pph, both.plainKeys, both.updates) + + survivor := new(pbinTestCorpus).storage(addr, pbinOracleSlot(257), 0x0B) + require.Equal(t, survivor.oracleRoot(t), root) +} + +// TestPBinLoadCellStateAbsentRead: neither arm has a value it may carry for a +// key the state no longer holds, so both refuse. +func TestPBinLoadCellStateAbsentRead(t *testing.T) { + t.Parallel() + + t.Run("storage", func(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + c := pbinTestEmptyCell() + c.kind = pbinNodeLeaf + c.storageAddrLen = length.Addr + length.Hash + copy(c.storageAddr[:], append(bytes.Clone(pbinOracleAddr(43)), pbinOracleSlot(1000)...)) + + require.ErrorIs(t, pph.loadCellState(&c), errPBinDeleteUnsupported) + }) + + t.Run("account", func(t *testing.T) { + t.Parallel() + + pph, _ := pbinTestEngine(t) + c := pbinTestEmptyCell() + c.kind = pbinNodeLeaf + c.accountAddrLen = length.Addr + copy(c.accountAddr[:], pbinOracleAddr(44)) + + require.ErrorIs(t, pph.loadCellState(&c), errPBinDeleteUnsupported) + }) +} + +// TestPBinAccountRemovalDropsBothSubtrees: an account owns its header stem and +// its storage prefix, and removing it removes those two subtrees whole — header +// storage slots included, and storage the fold was handed no list of. Its code +// chunks are content-addressed and shared, so they stay, and a bystander +// account must survive untouched. +func TestPBinAccountRemovalDropsBothSubtrees(t *testing.T) { + t.Parallel() + + addr, bystander := pbinOracleAddr(45), pbinOracleAddr(48) + code := bytes.Repeat([]byte{0x01}, 31*4) + stored := new(pbinTestCorpus). + accountWithCodeBytes(addr, 3, 7, code). + storage(addr, pbinOracleSlot(5), 0x01). // header window + storage(addr, pbinOracleSlot(256), 0x02). // storage zone + storage(addr, pbinOracleSlot(1<<20), 0x03). + account(bystander, 1, 2, common.Hash{0x48}) + + pph, ms := pbinTestEngine(t) + stored.applyTo(t, ms) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + removal := new(pbinTestCorpus).account(addr, 0, 0, common.Hash{}) + require.NoError(t, ms.applyPlainUpdates(removal.plainKeys, []Update{{Flags: DeleteUpdate}})) + + pph.Reset() + root := pbinTestProcess(t, pph, removal.plainKeys, removal.updates) + + survivor := new(pbinTestCorpus).account(bystander, 1, 2, common.Hash{0x48}) + want := survivor.entries(t) + codeHash := keccak.Sum256(code) + for i, chunk := range pbinChunkifyCode(code) { + want = append(want, pbinOracleEntry{key: pbinTreeKeyCodeChunk(codeHash, i), value: chunk[:]}) + } + wantRoot := pbinOracleRoot(want) + require.Equal(t, wantRoot[:], root, + "nothing of the removed account's own subtrees may survive, and nothing of the other may go") +} + +// TestPBinFoldDeleteRunsOnProcess: removing the last leaf of a subtree collapses +// it, and the collapse is observable as the zero-length record foldDelete writes +// at a bit-path key. storeRoot makes the sole other zero-length write, and only +// at the root key. +func TestPBinFoldDeleteRunsOnProcess(t *testing.T) { + t.Parallel() + + addr := pbinOracleAddr(46) + slots := []uint64{0, 1, 63, 256, 257, 258} + stored := new(pbinTestCorpus) + for i, slot := range slots { + stored.storage(addr, pbinOracleSlot(slot), byte(i+1)) + } + stored.account(pbinOracleAddr(47), 1, 2, common.Hash{0x47}) + + pph, ctx, ms := pbinTestStrictEngine(t) + require.NoError(t, ms.applyPlainUpdates(stored.plainKeys, stored.updates)) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + zeroed, want := new(pbinTestCorpus), new(pbinTestCorpus) + for _, slot := range slots { + zeroed.storage(addr, pbinOracleSlot(slot)) + } + // An absent key with no leaf of its own contributes nothing — the case a zero + // write over a live leaf must not be confused with. + zeroed.storage(addr, pbinOracleSlot(1<<20)) + want.account(pbinOracleAddr(47), 1, 2, common.Hash{0x47}) + + for i := range zeroed.plainKeys { + require.NoError(t, ms.applyPlainUpdates(zeroed.plainKeys[i:i+1], []Update{{Flags: DeleteUpdate}})) + } + ctx.puts = nil + pph.Reset() + root := pbinTestProcess(t, pph, zeroed.plainKeys, zeroed.updates) + require.Equal(t, want.oracleRoot(t), root) + + var collapsed int + for _, put := range ctx.puts { + if len(put.data) == 0 { + collapsed++ + } + } + require.NotZero(t, collapsed, "every stored leaf was zeroed, so subtrees must collapse") +} + +// TestPBinCollapsedRowLeavesNoRecord: removing one of a branch's two children +// collapses the row into its survivor, and the record the row was unfolded from +// has to go with it — an incremental removal must store exactly the records a +// rebuild of the same state stores. +func TestPBinCollapsedRowLeavesNoRecord(t *testing.T) { + t.Parallel() + + addr, bystander := pbinOracleAddr(51), pbinOracleAddr(52) + stored := new(pbinTestCorpus). + account(bystander, 1, 2, common.Hash{0x52}). + storage(addr, pbinOracleSlot(256), 0x01). + storage(addr, pbinOracleSlot(257), 0x02) + + pph, ms := pbinTestEngine(t) + stored.applyTo(t, ms) + pbinTestProcess(t, pph, stored.plainKeys, stored.updates) + + zeroed := new(pbinTestCorpus).storage(addr, pbinOracleSlot(257)) + require.NoError(t, ms.applyPlainUpdates(zeroed.plainKeys, []Update{{Flags: DeleteUpdate}})) + pph.Reset() + root := pbinTestProcess(t, pph, zeroed.plainKeys, zeroed.updates) + + survivors := new(pbinTestCorpus). + account(bystander, 1, 2, common.Hash{0x52}). + storage(addr, pbinOracleSlot(256), 0x01) + require.Equal(t, survivors.oracleRoot(t), root) + + _, fresh := pbinTestEngine(t) + survivors.applyTo(t, fresh) + freshEngine := NewPBinPatriciaHashed(fresh) + defer freshEngine.Release() + pbinTestProcess(t, freshEngine, survivors.plainKeys, survivors.updates) + + require.Equal(t, pbinLiveRecordKeys(fresh), pbinLiveRecordKeys(ms), + "the collapsed row's record outlived the node it described") +} + +func pbinLiveRecordKeys(ms *MockState) []string { + keys := make([]string, 0, len(ms.cm)) + for prefix, data := range ms.cm { + if len(data) > 0 { + keys = append(keys, fmt.Sprintf("%x", prefix)) + } + } + sort.Strings(keys) + return keys +} diff --git a/execution/commitment/testdata/binary_trie_vectors.json b/execution/commitment/testdata/binary_trie_vectors.json new file mode 100644 index 00000000000..9450f944051 --- /dev/null +++ b/execution/commitment/testdata/binary_trie_vectors.json @@ -0,0 +1,777 @@ +{ + "source": "ethereum/execution-specs projects/binary-trie", + "source_commit": "58faeb09b95fd022200974c7bf8a6c3e84712c25", + "trie_roots": [ + { + "name": "empty", + "entries": [], + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "single_leaf", + "entries": [ + { + "key": "0x00000000000000000000000000000000000000000000000000000000000000000000", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + } + ], + "root": "0x4b60a28dce9f3529d103a26e00fadb98514cbd16ce03b7df752426addef9bbc7" + }, + { + "name": "single_leaf_one_byte_key", + "entries": [ + { + "key": "0xab", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + } + ], + "root": "0x2ebeea9f8e2e4bbf6e4ff1b4cf8afbb641d4dfaf9a05469dea3787bbc35188d5" + }, + { + "name": "two_leaves_diverge_first_bit", + "entries": [ + { + "key": "0x00111111111111111111111111111111111111111111111111111111111111111111", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "key": "0x80111111111111111111111111111111111111111111111111111111111111111111", + "value": "0x0202020202020202020202020202020202020202020202020202020202020202" + } + ], + "root": "0x57210f2156bafa91dc33b7528fcfdb50660b902494b80a00347b28949df72816" + }, + { + "name": "two_leaves_diverge_last_bit", + "entries": [ + { + "key": "0x22222222222222222222222222222222222222222222222222222222222222222200", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "key": "0x22222222222222222222222222222222222222222222222222222222222222222201", + "value": "0x0202020202020202020202020202020202020202020202020202020202020202" + } + ], + "root": "0x606cacfcbf218928a25e67e40c8fa9cdf44d15cbc39be41da210cc86db128be9" + }, + { + "name": "three_leaves_shared_prefix", + "entries": [ + { + "key": "0xf0000000000000000000000000000000000000000000000000000000000000000000", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "key": "0xf1000000000000000000000000000000000000000000000000000000000000000000", + "value": "0x0202020202020202020202020202020202020202020202020202020202020202" + }, + { + "key": "0x0f000000000000000000000000000000000000000000000000000000000000000000", + "value": "0x0303030303030303030303030303030303030303030303030303030303030303" + } + ], + "root": "0x50ca5b44506c7aeac67017eef1be8977c69d8d1074b3c870ce9fc6ef0aa18163" + }, + { + "name": "mixed_key_lengths_34_and_66", + "entries": [ + { + "key": "0x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa05", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "key": "0xffbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb07", + "value": "0x0202020202020202020202020202020202020202020202020202020202020202" + } + ], + "root": "0x117ecc342fcf3753397737026c26e522b9b23e61cfb4c8aafa0b1c98ca5d507b" + }, + { + "name": "overwrite_takes_last_value", + "entries": [ + { + "key": "0x42424242424242424242424242424242424242424242424242424242424242424242", + "value": "0x0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "key": "0x42424242424242424242424242424242424242424242424242424242424242424242", + "value": "0x0202020202020202020202020202020202020202020202020202020202020202" + } + ], + "root": "0xe6817b5d8351669a295e51a0ae8459ace26faeb8904b8d9c7e3a00b9f343e0eb" + }, + { + "name": "random_50_keys_seed_8297", + "entries": [ + { + "key": "0x2aa6a8996ce6a78ab232d4ea1c1773f4216f5c6c16e580f784d1a03c7c4069f1b259", + "value": "0x602a3e5c20f394f60ad655f5a52a61487ce7121bc116b2d0036ad7e47298ab30" + }, + { + "key": "0xc44a5069709f82e5cfa1fdb523a09cbf72345d149135921a5ff4c22b590d5a7c6b32", + "value": "0x3fedde8f23715681af22f74b0d34825fb0cd1bb1a530e7e22b99c856937c3878" + }, + { + "key": "0x1d2c29c5a940de63446ffd493abe5469486948d20bcaf06d586dcd3e28507dbe4a4e", + "value": "0x557de7c40c7061a0e096cb67ee0d347c7e35b9d5fe896395ce2c79333c56e171" + }, + { + "key": "0xc62c92521ebf446fb986f84c4f8ea43e59ebf9744a30fccbea4ff32d0d2bd8c41568", + "value": "0x2cabc800545add40c21822901272b48ea6cccfa1574152725f54f504571b5bd3" + }, + { + "key": "0x4f0f6748864445c4f13e4c69d6b258bab15c7dde77da8d296c8c4de46cffb7518235", + "value": "0x11f71843a4e7dcc6fbe826921ed5bf7ff0db9a856f786c5e7662fcb016eb22ae" + }, + { + "key": "0xadbf5828b2759dea65fa9feef2e0580242769f423e60877107826d8de9fe19d6b77f", + "value": "0xecc05f6a68a2a05d955d25c1d2fa2d2c7d9b3948c70e4ba2117150041d162885" + }, + { + "key": "0x2a95680f5f67f0e0049573d9a08353549a058cc5fb50a87b2cadb92edd48f96b70a9", + "value": "0x52afae073e434b41f054a2d42d0783aaf89ab9cd5c1a96ba37d80fc6c222aa2a" + }, + { + "key": "0x7ca6b423f8f439ea5333e201ead1da2c3f3526aa0a4ea90c3467a4cbf3633c14a232", + "value": "0x2ea42ec85d724ce2cae8ff06532b670f94c7d0fffc6651d893d3dfaa605ba04c" + }, + { + "key": "0xd90768dee20dc8e1b17cb000c02d336c5e9115546fcf635a89e02aa72fcb4c35c079", + "value": "0x0ea636b852ecea23cbbf3600bed740a8a944adb3588d8d3f6d6cfe280b1d93a3" + }, + { + "key": "0x876a714a8fc09271610ec4abaf2ecc3ef7504527a6971bbcbd9cc0e0b1399fe933dc", + "value": "0x0fc1f11caa9f7ced770170dcf0c6006c014e5b8598c7e5c268a4203e207eeb9d" + }, + { + "key": "0x76b13611dabe295dc7e8df8402f1d43193a7891998570d7994a579186320cde1a7cf", + "value": "0xd4be299747047c39c7883b32836aed86acd8837f825a22bf260edf37cd998552" + }, + { + "key": "0xb537a077bf364c9e7e61842b3bdb47fc91e28e47954bc8cacb0a5eeeb0d0a32e09c7", + "value": "0x0d6c809686697d9ac4b0a6a4307459b6004e432933a5840e51aea8039deb341e" + }, + { + "key": "0x9581e69689729b644ddc033c7250173bde751225b7aae0bf279121fefe484499f225", + "value": "0xe0b5acd5c7b81bc11b2f923cb23b9725af51184dc9446950d59b3ccabad4336f" + }, + { + "key": "0x13af92c620778540042f70141701f7f364598d6ebb2822006938ca9559dcc9a5b0bd", + "value": "0x65665ad386602970d3d6194340f0e2dd673990c2eed2c11b5f7630cbf1ebef6e" + }, + { + "key": "0x30c89c776af10ea0a5d693cdc56e7cc26caef5a3d5dcc6a5e7f529004ef34608e2bb", + "value": "0xb10acf3de8bb5c127b7e898d2d31468eabf047610137763ef1327d56acf2c361" + }, + { + "key": "0x377ccaab099c7cfa7f22f0e82270e6bcb3e67c35f760c44d3f8d61cce78f37d3f5d6", + "value": "0xea6cd8bbfb9089722a00b8fe12de059493772ebda54edddf1163b16d90d118d8" + }, + { + "key": "0xe5c6b60b227487dabbcca806225c6a0ad42af15e465cb547595a8c185c87806741e1", + "value": "0xe127f236c860d50fc559abe9c734af3fd89099f4d9c3aea506237360ff72cbe3" + }, + { + "key": "0x952500b8e339aa344675e038772785efa67a9e820ff6c94f56f65c63056408e3351a", + "value": "0xa0b3499ec1f8120541ebaea047b0b6eec03967d9621b5136462c034aeb9fe483" + }, + { + "key": "0xedac6f4248259eab137c0d516cc36c9e43e8260444cc6d43200b4fc1a04e715897fc", + "value": "0xbf7049c0cdd86648fdfb8df979de2c5f28ce252ffb440132bad842de3d530aba" + }, + { + "key": "0xe97686bfc1d82e4195c44c37c63822b9a5790f13b85dc7fbad6e98e41ddb8da26fcf", + "value": "0xeed3aadd8b0587817d34d2de3c960c2c97d3f463e71d4b01e1e7672dbc615677" + }, + { + "key": "0x5e49a3cff08549e40d6bfa45cf4751e681323a0a90747af1fbf2a09029d4054c425d", + "value": "0x11ce34b22799c2e56c0fca48683d2befc6a3424a2c8808ed8f80f02f0bf74107" + }, + { + "key": "0xd49a56c989b8250924ace1b95496057c7e79a48be406ff0f033e9867114d5b28eefe", + "value": "0x4221245e55f174c502b407002d59f809841fae554c37b2c28bf5a56307ba4f4b" + }, + { + "key": "0xb8ec5602446a4bf77aa7f76e2d9080611acf14d59e868273574320059a7cce2fc56f", + "value": "0xc0ac6eebfbbb6650e31532413e0e2707a9724cf3b85a45b1e1a524079bfed6f6" + }, + { + "key": "0x63b5fad598b5ac70ac16669d0674b92588710e1f9eb4ecba96ffd3fca62bc81b9fd4", + "value": "0xdc23fe99d39f5d3d5c1fb403a1fa18f537565f634f9750296b07eed92ac295e9" + }, + { + "key": "0x41580a465c0b267026210bdaac106834115822dbf82975cb3187846c0750150b7da8", + "value": "0xc4350d9d1e38ea02d65518957de948ba86b3366e076fd7c2f701b50efa613601" + }, + { + "key": "0x40ee13ffd4d197e1661c65006cbb0dd5728e8236565299eb84063809b26a4e66c23c", + "value": "0xbaf01926523cff84ff29b32d4d19ff4e1fb222ea0187323675b04357c2bd659a" + }, + { + "key": "0xb2d9269956fe58d83f174b8d9fa80d6f7cf195b3e062ae55bd3cdb53d92308b4bd29", + "value": "0x939ed45a7cd62584b0ce4c549609bfdc80de4c33091f5a4d4062d07c09b3573e" + }, + { + "key": "0x5fcfe5231af181ca8711c3e3638ca8f9a6825431131840eccb2e00f8f341eb0e1f33", + "value": "0xe159bb37bea013607a9568c8c5c9c55129fca8aed6f619a2ffc14f29699dcc28" + }, + { + "key": "0x45dc75b619130f903c33151209e131665173d6da28f554a549a5563ad40bd8fa7a40", + "value": "0xeda30ecb83f08dc40f4c8b892b55ea3e8e30e11a67bf20a32c3015668b3797f2" + }, + { + "key": "0x4d2957cf1e0ab2d3bc27c3a7935b3390face7d07c5d73581a6082730c563265a3589", + "value": "0xf6c96dfd3b2ca036f2f025f1103ad5f23e653a5f0964b46355b892f3eab8facd" + }, + { + "key": "0x933625c1c382f8a83e243d584346e3b94e14b0ac5427cb84a0580b4568d2c91aa706", + "value": "0xe33ffa5d02f23d96e83344e80ccc48fca6036c87a4bac368a24ec28a26bb70c3" + }, + { + "key": "0x20455de6ec9ad2b33b187e1738be0e6b41c299ce96940837e49eea316ad1b6b825ee", + "value": "0x82dfb2bcce1e0f9d8b0230c432d05fbb6aaea5dcf13d7ab0709b89b1ceefb745" + }, + { + "key": "0xfda49bac1959069e75394a653a4e44575539f06deafaa084702605e71c00c9b8e81a", + "value": "0x0f626cda6806b58620931dad579dcb887592e8b46f94acf6831b7d3e0bf9076f" + }, + { + "key": "0xef257a2eb664173e42d4ae098eb635434a74c3c37fab969be2e758b1ca0c3a4d390d", + "value": "0x091b81cb0449892c17511cf1529e2f2666af2671b68d155488de18621f0ad1f8" + }, + { + "key": "0xfa3082b0bd39b1fb7011212ae0c3b8b78f6ce9450069eab37c7ffce74ec93d28e949", + "value": "0x300042c9c0d21bab7634484dfa54ae0dd4d579d23107c2c23f805cdcab1f9bc3" + }, + { + "key": "0x0f35b7fd43411a98bd2f77d1d75f9a44b6fd9ae514b9e12a9c94cf5cdefc3460d5d2", + "value": "0xa2d8179579efa9284a8c5d602825290471c426b06f648cdab864615d7ea33e37" + }, + { + "key": "0x5ae984fa36087b9eead78eafd1219a682441ee126a32522e5dd3f1d0a8555e9a3ca4", + "value": "0x2ccb3878ecf6016742eeb5cf1fa88186c65fad7f59e0519510311bc361d4dee4" + }, + { + "key": "0x62fcb1d41412ffc7c74768f39558646479095f648d3fd045ef2d857ea4740752e6e3", + "value": "0x09af31d454b6c1aa915dc28e5ab362396feb89ede60a5d617f14b3de0edc7012" + }, + { + "key": "0xacc4a501bfd10b9d890407484618059b0dd03ed7c759ca881847e201b4d7f326e233", + "value": "0xc5671cb9b0fb74823fd88bf2df0795602f8532b33ea7cb06a90bb6bc4d64013a" + }, + { + "key": "0x5d3305b1d38d84f22ab72f4cff4997f347e91801e2f01337cdae981e9224258a21ce", + "value": "0x8d578546affb0976f291db18f6ebe99e9dc47ec625003f7941861ac52dda72ff" + }, + { + "key": "0x9345d08e09992493c0f9d2f0b1181ed7a123879a02af0bd7c152f6ad75792efa6f96", + "value": "0xcc59af9043525c027d6c392a62f5f2c5d4626f6b70c7c155d5dbfbec73535f1c" + }, + { + "key": "0x334fbdf232ba304810bb66b0d99caf22fc6b9e35ccb1e470688157c5d767029b3add", + "value": "0x2f5470a09aa7e14347be86198b30ea13d6dfba5ebc2d406a8380141ca4993ecd" + }, + { + "key": "0x5f0fb320d16cf97ef07303475f28e0f72b93ae9689be3fe9c50d4bcd2e13e5456fa2", + "value": "0x31c23f0aa04789347e82acd04248dde92bd11674591f24e09d570cc6a782df11" + }, + { + "key": "0x8db965afa0f3685791b7747fac9b55fe6c9dc680654ceb7bd346be02037810e8cb4b", + "value": "0x8a9ed5de1a61fd15b89879d1f6e7d2dfe53238d9bdd9e0af088d05cb1427c966" + }, + { + "key": "0x3cbafb9218233c982e5c84fda1ded1146c2b208160213ea77ed397c125a55310204e", + "value": "0x9e4483e2abc56cbf79b1ea996ec0aacb171ab36f85643bf0f3121a6185964b72" + }, + { + "key": "0x353d69bbc4438bbae4f1006fb980dba7537ecb1614e8d3c123a678720ca9172144dc", + "value": "0x83af19ff2f7f5c89be64c268a9b01837c26e134a170969bad4bcea74f7ee2c1b" + }, + { + "key": "0xcd492f6376b541704fcbd979aaf13147eace07f547497470761e2c980b3fcd46ce44", + "value": "0x94355b1cbd394a0e65112e6eaa08c5293c0b82eb44b89b5eedbb433b74a8e9ed" + }, + { + "key": "0xec2be3bc6b4bdf9944757aa679fefc6f00a3e534ccf1f5045163d94a992d439c20d7", + "value": "0x4f2581fc51143abd11b62ad6de6fd4f3aaf1fe1a2f40a2b882c990c3db4b3c7e" + }, + { + "key": "0x770900a7fbc1f91e3bb2d425f8f65126f69b1cb601962360a44deb58208ab5c18b4d", + "value": "0x53fb7e26d5248117e143ba30c50481564cf4c02b3fa7ad41462423ca237cf851" + }, + { + "key": "0x11d51e7adf1f2771881bb2f625df7b201ab7719e6cbcf565e28b82bb899709d8581a", + "value": "0x9e428672e42d43194d4c9e84e2c9be48b1f0293bc983a847e38ee4743406631c" + } + ], + "root": "0xd966e4d5b3676b62c732a8c267753f375226322ec44b1c1d4f8f8c40de77e9be" + } + ], + "embedding": { + "address20": "0x00112233445566778899aabbccddeeff00112233", + "address32": "0x00000000000000000000000000112233445566778899aabbccddeeff00112233", + "basic_data_key": "0x00f4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a0800", + "code_hash_key": "0x00f4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a0801", + "delegation_key": "0x00f4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a0802", + "storage_slot_keys": { + "0": "0x00f4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a0840", + "1": "0x00f4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a0841", + "63": "0x00f4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a087f", + "64": "0xfff4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a08b7b7ba8d57e997347b504830cfb1837de0bb46da8c5c53654442588e0ca0bdbf40", + "255": "0xfff4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a08b7b7ba8d57e997347b504830cfb1837de0bb46da8c5c53654442588e0ca0bdbfff", + "256": "0xfff4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a085b15bdc9241d79c981f6bf4ae56cf1e77d1f9350cf73372e6d792c1c6eb13b3000", + "511": "0xfff4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a085b15bdc9241d79c981f6bf4ae56cf1e77d1f9350cf73372e6d792c1c6eb13b30ff", + "512": "0xfff4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a0808fc1122a8c0a65fbdf45fd999b8b9a4f1e09fd74bf5cc02c51d701972a861b000", + "1606938044258990275541962092341162602522202993782792835301376": "0xfff4e42504054ae2ba2c9aab59b7cafad1e3df583c385d10fcb8ab0a0ab82e7a08de40820dcd8994eedc4c374fd005321c147c12928ed5a191b1a0406bd81a0d7000" + }, + "code_chunk_keys": { + "0": "0x01348ace48ac0a7316c5dee2e4e5a680ba01413aa18e07403f3c00b4f82f2da55800", + "1": "0x01348ace48ac0a7316c5dee2e4e5a680ba01413aa18e07403f3c00b4f82f2da55801", + "255": "0x01348ace48ac0a7316c5dee2e4e5a680ba01413aa18e07403f3c00b4f82f2da558ff", + "256": "0x01d2482e6e552436a97975ef92aba4baec1ea2f25a2188a2aac744c6d86ac7e3de00", + "257": "0x01d2482e6e552436a97975ef92aba4baec1ea2f25a2188a2aac744c6d86ac7e3de01", + "511": "0x01d2482e6e552436a97975ef92aba4baec1ea2f25a2188a2aac744c6d86ac7e3deff", + "512": "0x015b73478e5bf9061bc84d75522ca707958946ef691b0a72194afbcb7089aa761d00", + "2114": "0x01ced5e67b5c39c6dd7dfdcf6c13447e7cbe9c2cfdb73ac5059517b389187134d942" + }, + "code_hash": "0xbcc90f2d6dada5b18e155c17a1c0a55920aae94f39857d39d0d8ed07ae8f228b" + }, + "chunkify_code": [ + { + "name": "empty", + "code": "0x", + "chunks": [] + }, + { + "name": "stop_padded", + "code": "0x00", + "chunks": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "name": "eip_example_push4_boundary", + "code": "0x010101010101010101010101010101010101010101010101010101010163aabbccdd01010101010101010101", + "chunks": [ + "0x00010101010101010101010101010101010101010101010101010101010163aa", + "0x03bbccdd01010101010101010101000000000000000000000000000000000000" + ] + }, + { + "name": "push32_at_chunk_end_spills_31", + "code": "0x0101010101010101010101010101010101010101010101010101010101017f000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0101010101", + "chunks": [ + "0x000101010101010101010101010101010101010101010101010101010101017f", + "0x1f000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e", + "0x011f010101010100000000000000000000000000000000000000000000000000" + ] + } + ], + "encode_basic_data": [ + { + "code_size": 0, + "nonce": 0, + "balance": "0x0", + "encoded": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "code_size": 1234, + "nonce": 42, + "balance": "0xde0b6b3a7640000", + "encoded": "0x00000000000004d2000000000000002a00000000000000000de0b6b3a7640000" + }, + { + "code_size": 4294967295, + "nonce": 18446744073709551615, + "balance": "0xffffffffffffffffffffffffffffffff", + "encoded": "0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "pbt_state": [ + { + "name": "empty_state", + "accounts": {}, + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "single_eoa", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 3, + "balance": "0xde0b6b3a7640000", + "code": "0x", + "code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "storage": {} + } + }, + "root": "0x61442bd142d34312a0b3e6216c0f08422f3c32a659bff2880bc74afa83fa880d" + }, + { + "name": "eoa_zero_nonce_and_balance", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 0, + "balance": "0x0", + "code": "0x", + "code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "storage": {} + } + }, + "root": "0x6a065b1de86242ec9f94d244dc2d53cfd8f3739426b88c463748bb850f1351d8" + }, + { + "name": "code_with_push_data_spill", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x10000000000000000", + "code": "0x0101010101010101010101010101010101010101010101010101010101017f000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0101010101", + "code_hash": "0x13c3c160f495b78b684f963ea72682524a2e1d1e24b612a40ff9f04a592cedf1", + "storage": {} + } + }, + "root": "0xd1b803b37f66213a264ef88057e472d66cf76fefaf05ad0b9d71045a1412bd84" + }, + { + "name": "code_and_boundary_storage", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + "code_hash": "0x70c8f40cf22ebddfdd1c66a0e1891a29f4ae670f4ca055f379a106209e074165", + "storage": { + "63": "0x0000000000000000000000000000000000000000000000000000000000000001", + "64": "0x0000000000000000000000000000000000000000000000000000000000000002", + "256": "0x0000000000000000000000000000000000000000000000000000000000000003" + } + } + }, + "root": "0x84d204064e6f2d3f8862bf399d9c1d7eb46a47d041930beec3c1d1dd124e6bc8" + }, + { + "name": "code_across_the_group_boundary", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x0101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + "code_hash": "0x84a8136016d2be33610963c8193847a50c9a06787aa7ac7b2b0a1bf5069b4501", + "storage": {} + } + }, + "root": "0x2535d4a3b50552eb629d8ee2aa1479ad45eac1ee1dd035f036301a5b53a898d3" + }, + { + "name": "storage_across_the_header_boundary", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x", + "code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "storage": { + "0": "0x0000000000000000000000000000000000000000000000000000000000000001", + "1": "0x0000000000000000000000000000000000000000000000000000000000000002", + "63": "0x0000000000000000000000000000000000000000000000000000000000000003", + "64": "0x0000000000000000000000000000000000000000000000000000000000000004", + "255": "0x0000000000000000000000000000000000000000000000000000000000000005", + "256": "0x0000000000000000000000000000000000000000000000000000000000000006", + "115792089237316195423570985008687907853269984665640564039457584007913129639935": "0x0000000000000000000000000000000000000000000000000000000000000007" + } + } + }, + "root": "0xe35d57fc71e60a19fac7599ea972cee07c2102bdf4f5e23b8cffddd9e107f200" + }, + { + "name": "zero_storage_slot_is_absent", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x", + "code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "storage": { + "7": "0x0000000000000000000000000000000000000000000000000000000000000000", + "8": "0x0000000000000000000000000000000000000000000000000000000000000009", + "300": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "root": "0x3b49e87b5b3c049828dd881ec4cbf355d0fab3813c58b785dd6519d314bdda96" + }, + { + "name": "full_header_occupancy", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x0101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + "code_hash": "0xe360036fb72811ee2775a32b4a10ea2b47029d6ed28783c5f7b151d7fda1a938", + "storage": { + "0": "0x0000000000000000000000000000000000000000000000000000000000000001", + "1": "0x0000000000000000000000000000000000000000000000000000000000000002", + "2": "0x0000000000000000000000000000000000000000000000000000000000000003", + "3": "0x0000000000000000000000000000000000000000000000000000000000000004", + "4": "0x0000000000000000000000000000000000000000000000000000000000000005", + "5": "0x0000000000000000000000000000000000000000000000000000000000000006", + "6": "0x0000000000000000000000000000000000000000000000000000000000000007", + "7": "0x0000000000000000000000000000000000000000000000000000000000000008", + "8": "0x0000000000000000000000000000000000000000000000000000000000000009", + "9": "0x000000000000000000000000000000000000000000000000000000000000000a", + "10": "0x000000000000000000000000000000000000000000000000000000000000000b", + "11": "0x000000000000000000000000000000000000000000000000000000000000000c", + "12": "0x000000000000000000000000000000000000000000000000000000000000000d", + "13": "0x000000000000000000000000000000000000000000000000000000000000000e", + "14": "0x000000000000000000000000000000000000000000000000000000000000000f", + "15": "0x0000000000000000000000000000000000000000000000000000000000000010", + "16": "0x0000000000000000000000000000000000000000000000000000000000000011", + "17": "0x0000000000000000000000000000000000000000000000000000000000000012", + "18": "0x0000000000000000000000000000000000000000000000000000000000000013", + "19": "0x0000000000000000000000000000000000000000000000000000000000000014", + "20": "0x0000000000000000000000000000000000000000000000000000000000000015", + "21": "0x0000000000000000000000000000000000000000000000000000000000000016", + "22": "0x0000000000000000000000000000000000000000000000000000000000000017", + "23": "0x0000000000000000000000000000000000000000000000000000000000000018", + "24": "0x0000000000000000000000000000000000000000000000000000000000000019", + "25": "0x000000000000000000000000000000000000000000000000000000000000001a", + "26": "0x000000000000000000000000000000000000000000000000000000000000001b", + "27": "0x000000000000000000000000000000000000000000000000000000000000001c", + "28": "0x000000000000000000000000000000000000000000000000000000000000001d", + "29": "0x000000000000000000000000000000000000000000000000000000000000001e", + "30": "0x000000000000000000000000000000000000000000000000000000000000001f", + "31": "0x0000000000000000000000000000000000000000000000000000000000000020", + "32": "0x0000000000000000000000000000000000000000000000000000000000000021", + "33": "0x0000000000000000000000000000000000000000000000000000000000000022", + "34": "0x0000000000000000000000000000000000000000000000000000000000000023", + "35": "0x0000000000000000000000000000000000000000000000000000000000000024", + "36": "0x0000000000000000000000000000000000000000000000000000000000000025", + "37": "0x0000000000000000000000000000000000000000000000000000000000000026", + "38": "0x0000000000000000000000000000000000000000000000000000000000000027", + "39": "0x0000000000000000000000000000000000000000000000000000000000000028", + "40": "0x0000000000000000000000000000000000000000000000000000000000000029", + "41": "0x000000000000000000000000000000000000000000000000000000000000002a", + "42": "0x000000000000000000000000000000000000000000000000000000000000002b", + "43": "0x000000000000000000000000000000000000000000000000000000000000002c", + "44": "0x000000000000000000000000000000000000000000000000000000000000002d", + "45": "0x000000000000000000000000000000000000000000000000000000000000002e", + "46": "0x000000000000000000000000000000000000000000000000000000000000002f", + "47": "0x0000000000000000000000000000000000000000000000000000000000000030", + "48": "0x0000000000000000000000000000000000000000000000000000000000000031", + "49": "0x0000000000000000000000000000000000000000000000000000000000000032", + "50": "0x0000000000000000000000000000000000000000000000000000000000000033", + "51": "0x0000000000000000000000000000000000000000000000000000000000000034", + "52": "0x0000000000000000000000000000000000000000000000000000000000000035", + "53": "0x0000000000000000000000000000000000000000000000000000000000000036", + "54": "0x0000000000000000000000000000000000000000000000000000000000000037", + "55": "0x0000000000000000000000000000000000000000000000000000000000000038", + "56": "0x0000000000000000000000000000000000000000000000000000000000000039", + "57": "0x000000000000000000000000000000000000000000000000000000000000003a", + "58": "0x000000000000000000000000000000000000000000000000000000000000003b", + "59": "0x000000000000000000000000000000000000000000000000000000000000003c", + "60": "0x000000000000000000000000000000000000000000000000000000000000003d", + "61": "0x000000000000000000000000000000000000000000000000000000000000003e", + "62": "0x000000000000000000000000000000000000000000000000000000000000003f", + "63": "0x0000000000000000000000000000000000000000000000000000000000000040" + } + } + }, + "root": "0xcc42106e8d0eeb48e1c5fe68dd8893908de01d318641a4270e64d178e401b78c" + }, + { + "name": "shared_bytecode_two_accounts", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x1", + "code": "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + "code_hash": "0x70c8f40cf22ebddfdd1c66a0e1891a29f4ae670f4ca055f379a106209e074165", + "storage": {} + }, + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "nonce": 2, + "balance": "0x2", + "code": "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + "code_hash": "0x70c8f40cf22ebddfdd1c66a0e1891a29f4ae670f4ca055f379a106209e074165", + "storage": {} + } + }, + "root": "0xf1b98ddd9b35b8444abf1c6f36d6c4ac3203c2927efa415e6803fa3a8151e719" + }, + { + "name": "short_shared_code_two_accounts", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x1", + "code": "0xfefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefe", + "code_hash": "0xde4f73676a0de2b9bf587ccb2007d5c1c7f9dd6efa65b507eff39f44bd00fe89", + "storage": {} + }, + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "nonce": 2, + "balance": "0x2", + "code": "0xfefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefe", + "code_hash": "0xde4f73676a0de2b9bf587ccb2007d5c1c7f9dd6efa65b507eff39f44bd00fe89", + "storage": {} + } + }, + "root": "0x12cc9c7f1890c044657ebf1f2e6ddaaf29ab8e6e972de60dc9d4f131fae2e4d2" + }, + { + "name": "delegation_designator", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0xef0100cccccccccccccccccccccccccccccccccccccccc", + "code_hash": "0xf50ecf6d1bbb82ea6d6efb33e0cff6b48a6946a9dce2445b330537fa26d12e6d", + "storage": {} + } + }, + "root": "0x54f3446a8fb2084f179500f728221fed07d3d2f4ca46117fa6465ab91276908c" + }, + { + "name": "two_authorities_one_target", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x1", + "code": "0xef0100cccccccccccccccccccccccccccccccccccccccc", + "code_hash": "0xf50ecf6d1bbb82ea6d6efb33e0cff6b48a6946a9dce2445b330537fa26d12e6d", + "storage": {} + }, + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "nonce": 2, + "balance": "0x2", + "code": "0xef0100cccccccccccccccccccccccccccccccccccccccc", + "code_hash": "0xf50ecf6d1bbb82ea6d6efb33e0cff6b48a6946a9dce2445b330537fa26d12e6d", + "storage": {} + } + }, + "root": "0xf6a805ce1770a4549d4bbd4c55d55fd9f0f49e98161912c34ef4bdd3a44ee9cb" + }, + { + "name": "delegation_with_storage", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0xef0100cccccccccccccccccccccccccccccccccccccccc", + "code_hash": "0xf50ecf6d1bbb82ea6d6efb33e0cff6b48a6946a9dce2445b330537fa26d12e6d", + "storage": { + "0": "0x0000000000000000000000000000000000000000000000000000000000000001", + "63": "0x0000000000000000000000000000000000000000000000000000000000000002", + "64": "0x0000000000000000000000000000000000000000000000000000000000000003" + } + } + }, + "root": "0x0eb6d553cb8eb7d227c98855e12bedfa83ff896c16fcfc125bc597d92a9aadf6" + }, + { + "name": "code_hash_starting_with_the_delegation_marker", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x0000000000000000000000000000000000000000637401", + "code_hash": "0xef0100f360bf074f90f948bcf767f30c5c3717d735b6af03fdf7efff4fcc2ecf", + "storage": {} + } + }, + "root": "0x3f1cf36a116be3dcdec395fc44fdb92ea71511471ec0bf7c447c7b40ef4a4396" + }, + { + "name": "code_chunks_of_zero_bytes", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 1, + "balance": "0x0", + "code": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "code_hash": "0x2e795758918d9c804da815b3be88b798e63d21d668c624228fbd697bff25ea3b", + "storage": {} + } + }, + "root": "0x4001782ffd46a182d4a4beaec5ff32141776ed98f415651733252098a7dac362" + }, + { + "name": "max_basic_data_fields", + "accounts": { + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "nonce": 9007199254740991, + "balance": "0xffffffffffffffffffffffffffffffff", + "code": "0x", + "code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "storage": {} + } + }, + "root": "0x8331ded43e04d0fadfb2e42ef91add88b92e6b4ff1f5f5054f0e8a17e3638a52" + }, + { + "name": "random_6_accounts_seed_8297", + "accounts": { + "0x2aa6a8996ce6a78ab232d4ea1c1773f4216f5c6c": { + "nonce": 3864742092, + "balance": "0xd80b4d69597c60e1efb8a4ff0b5daa13", + "code": "0x", + "code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "storage": { + "23810349356214295913386548550719634316398909742729249082281881830725930739838": "0xebe10c5568a6acd14237bcf6d560d2cf7bf251d5400f68d4a0ac7615939ea857", + "96523555122766932062082486665246314608966373848871865152670980275761706763325": "0x1f54549ffb51a4a315716295306a7c53f0755d81fee7a7182c8b68ac9864d4a0", + "98591652884780845594215434683999800137086927586913902574991246821904145534181": "0xc481a4a2ad7cb9b16b591edaeabae84dc6f30fd205676b14a6dabb16b2231c1b" + } + }, + "0x036ad7e47298ab30c44a5069709f82e5cfa1fdb5": { + "nonce": 1556638775, + "balance": "0xe51b7a8af4367d872665b2247c239cd9", + "code": "0x23a09cbf72345d149135921a5ff4c22b590d5a7c6b323fedde8f23715681af22f74b0d34825fb0cd1bb1a530e7e22b99c856937c38781d2c29c5a940de63446ffd493abe5469486948d20bcaf06d586dcd3e28507dbe4a4e557de7c40c7061a0e096cb67", + "code_hash": "0x59ccc5659d64aafe203bfcb44b1a034853791063da1d6e2006286d4635e57749", + "storage": { + "22481324409851482507451361461257995159359285672655871475285301068545285417875": "0x8b4d0643d5e14a6fe484b99cbbd3281606937fd09393f867f17288dba672eb34", + "108540624023827917680660341181296584989878088019874691452633636111559395721206": "0xb6000e0f19c549363c9012401673c61df3acd2dac6c688efe9a8e051c5955969" + } + }, + "0x8ea43e59ebf9744a30fccbea4ff32d0d2bd8c415": { + "nonce": 735618664, + "balance": "0x2468d9b033c3fd72d099fa1ec7973061", + "code": "0x2c", + "code_hash": "0x3e7a35b97029f9e0cf6effd71c1a7958822e9a217d3a3aec886668a7dd8231cb", + "storage": { + "4171655909082161865571109005100230898954774310874913228405678276964421062338": "0xa4e1a791a94779f02d3fd4add43345f82a7a594f0041ee04a4632870646bea97", + "25836125176906852663030524785766215270304214778391188685675587848691358961258": "0xc357d7e167b39624e9c1edd366617e585379106388d2c44c4731dddbc5abb83f" + } + }, + "0x4445c4f13e4c69d6b258bab15c7dde77da8d296c": { + "nonce": 1185213493, + "balance": "0x7f63d0eddc50e2b1ffbcd4b981e866df", + "code": "0x4de46cffb751823511f71843a4e7dcc6fbe826921ed5bf7ff0db9a856f786c", + "code_hash": "0xdc1f5e259ca6e190357dd69f6640852a2d92ae6f45336d0863faed4b1a05f2e2", + "storage": { + "19942743671895068441030546052764156067014987410011317649363496461376689888165": "0x0b1d452e5838ae487e65db179f3ba1d8b82dbff33167b442d37f8e1d3b6632fd", + "58654395628803392503698366458431685516538035156756763504726343391399085975384": "0x99efd577b88168ffa98494f2b366690e4efde683aeb9330eb88ccfe33afc2613", + "1626051957501994272606654322465618868497675113599288733974508228147317687749": "0xa91d606cfbfa6980211ed20601433a4a2c7b428fd8c4284070701aedd7b03285" + } + }, + "0xd6b77fecc05f6a68a2a05d955d25c1d2fa2d2c7d": { + "nonce": 2452887031, + "balance": "0xf28ff04128411a867d9165b662ee783a", + "code": "0x3948c70e4ba2117150041d1628852a95680f5f67f0e0049573d9a08353549a", + "code_hash": "0xd64095e60edee2e39710bbd761df130eefaf2e3182a4a6b637a6281077b4bbc3", + "storage": {} + }, + "0x7b2cadb92edd48f96b70a952afae073e434b41f0": { + "nonce": 3485188628, + "balance": "0x5a1d9ca3fc2c8309535c7b76fbb29863", + "code": "0xa2", + "code_hash": "0x5817a817284a25996cf471299ba31908b9ff7bb9b4ec073d781021f971c8af66", + "storage": { + "106584524199836718886300765010566265940520737683892308674385881765558924295996": "0x41ca6dc003d765a9b51f1432fc4ec85616b6933eb6916ca0b2e73decd1fa5dfd", + "97637941765767869768932293193125868376845616129893957383796993607466809060766": "0x9bd755219717db6ddadc9518a376a0834b56ff750d0bdd4fe3cfab432e523ade" + } + } + }, + "root": "0xa5e4c911120339fa0de57ae06c90abbec39d87a7d753c9c10e7701473e60463f" + } + ] +} diff --git a/execution/commitment/testdata/eip8297_vectors.json b/execution/commitment/testdata/eip8297_vectors.json new file mode 100644 index 00000000000..f510ac859c4 --- /dev/null +++ b/execution/commitment/testdata/eip8297_vectors.json @@ -0,0 +1,1858 @@ +{ + "meta": { + "source": "execution-specs@ec412acfd (branch eip-8297-tests)", + "hasher": "blake3", + "generator": "export_vectors.py" + }, + "empty_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "trie_vectors": [ + { + "name": "empty", + "entries": [], + "root": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "single_account_leaf", + "entries": [ + { + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e400", + "value": "0x0000000000000000000000000000000000000000000000000000000000000007" + } + ], + "root": "0x22da077ee89a0e6e6259303169fb6c7a3133fafa3033515a355af9c4f9ed5ea0" + }, + { + "name": "one_header_stem_two_leaves", + "entries": [ + { + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e400", + "value": "0x0000000000000000000000000000000000000000000000000000000000000007" + }, + { + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e401", + "value": "0x0000000000000000000000000000000000000000000000000000000000000009" + } + ], + "root": "0x3599d1dd23fef634f3845fd935375be8a42169ecc90906632aa8afa2fa380812" + }, + { + "name": "two_accounts", + "entries": [ + { + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e400", + "value": "0x0000000000000000000000000000000000000000000000000000000000000007" + }, + { + "key": "0x0034bb4f3468340d5f72275da92420aed94db48bbc61823e92f29f14f51590373a00", + "value": "0x0000000000000000000000000000000000000000000000000000000000000008" + } + ], + "root": "0xbcca03773c89779e136f983eba516339660c732d5b0a1ccfc2c725dca29eefbc" + }, + { + "name": "cross_zone_small", + "entries": [ + { + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e400", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e401", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002" + }, + { + "key": "0xff55bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e406f5e5117ba26652e3cbef5ea24cc46f42709eb47be3c46f3a923b68a67f44c264", + "value": "0x0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "key": "0xff55bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e4f1ce240e3efd0b0855a335ac6b58ea33be1b2afb193608d6d21fd91308dd74bc64", + "value": "0x0000000000000000000000000000000000000000000000000000000000000004" + }, + { + "key": "0x0034bb4f3468340d5f72275da92420aed94db48bbc61823e92f29f14f51590373a00", + "value": "0x0000000000000000000000000000000000000000000000000000000000000005" + } + ], + "root": "0x3fc0f35560e81b2b1bc349decef42eb48a614d8497646e401e7b85abc0b16a30" + }, + { + "name": "full_header_stem", + "entries": [ + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce00", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce01", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce02", + "value": "0x0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce03", + "value": "0x0000000000000000000000000000000000000000000000000000000000000004" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce04", + "value": "0x0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce05", + "value": "0x0000000000000000000000000000000000000000000000000000000000000006" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce06", + "value": "0x0000000000000000000000000000000000000000000000000000000000000007" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce07", + "value": "0x0000000000000000000000000000000000000000000000000000000000000008" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce08", + "value": "0x0000000000000000000000000000000000000000000000000000000000000009" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce09", + "value": "0x000000000000000000000000000000000000000000000000000000000000000a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce0a", + "value": "0x000000000000000000000000000000000000000000000000000000000000000b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce0b", + "value": "0x000000000000000000000000000000000000000000000000000000000000000c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce0c", + "value": "0x000000000000000000000000000000000000000000000000000000000000000d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce0d", + "value": "0x000000000000000000000000000000000000000000000000000000000000000e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce0e", + "value": "0x000000000000000000000000000000000000000000000000000000000000000f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce0f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000010" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce10", + "value": "0x0000000000000000000000000000000000000000000000000000000000000011" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce11", + "value": "0x0000000000000000000000000000000000000000000000000000000000000012" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce12", + "value": "0x0000000000000000000000000000000000000000000000000000000000000013" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce13", + "value": "0x0000000000000000000000000000000000000000000000000000000000000014" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce14", + "value": "0x0000000000000000000000000000000000000000000000000000000000000015" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce15", + "value": "0x0000000000000000000000000000000000000000000000000000000000000016" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce16", + "value": "0x0000000000000000000000000000000000000000000000000000000000000017" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce17", + "value": "0x0000000000000000000000000000000000000000000000000000000000000018" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce18", + "value": "0x0000000000000000000000000000000000000000000000000000000000000019" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce19", + "value": "0x000000000000000000000000000000000000000000000000000000000000001a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce1a", + "value": "0x000000000000000000000000000000000000000000000000000000000000001b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce1b", + "value": "0x000000000000000000000000000000000000000000000000000000000000001c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce1c", + "value": "0x000000000000000000000000000000000000000000000000000000000000001d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce1d", + "value": "0x000000000000000000000000000000000000000000000000000000000000001e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce1e", + "value": "0x000000000000000000000000000000000000000000000000000000000000001f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce1f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000020" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce20", + "value": "0x0000000000000000000000000000000000000000000000000000000000000021" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce21", + "value": "0x0000000000000000000000000000000000000000000000000000000000000022" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce22", + "value": "0x0000000000000000000000000000000000000000000000000000000000000023" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce23", + "value": "0x0000000000000000000000000000000000000000000000000000000000000024" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce24", + "value": "0x0000000000000000000000000000000000000000000000000000000000000025" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce25", + "value": "0x0000000000000000000000000000000000000000000000000000000000000026" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce26", + "value": "0x0000000000000000000000000000000000000000000000000000000000000027" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce27", + "value": "0x0000000000000000000000000000000000000000000000000000000000000028" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce28", + "value": "0x0000000000000000000000000000000000000000000000000000000000000029" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce29", + "value": "0x000000000000000000000000000000000000000000000000000000000000002a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce2a", + "value": "0x000000000000000000000000000000000000000000000000000000000000002b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce2b", + "value": "0x000000000000000000000000000000000000000000000000000000000000002c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce2c", + "value": "0x000000000000000000000000000000000000000000000000000000000000002d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce2d", + "value": "0x000000000000000000000000000000000000000000000000000000000000002e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce2e", + "value": "0x000000000000000000000000000000000000000000000000000000000000002f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce2f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000030" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce30", + "value": "0x0000000000000000000000000000000000000000000000000000000000000031" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce31", + "value": "0x0000000000000000000000000000000000000000000000000000000000000032" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce32", + "value": "0x0000000000000000000000000000000000000000000000000000000000000033" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce33", + "value": "0x0000000000000000000000000000000000000000000000000000000000000034" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce34", + "value": "0x0000000000000000000000000000000000000000000000000000000000000035" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce35", + "value": "0x0000000000000000000000000000000000000000000000000000000000000036" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce36", + "value": "0x0000000000000000000000000000000000000000000000000000000000000037" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce37", + "value": "0x0000000000000000000000000000000000000000000000000000000000000038" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce38", + "value": "0x0000000000000000000000000000000000000000000000000000000000000039" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce39", + "value": "0x000000000000000000000000000000000000000000000000000000000000003a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce3a", + "value": "0x000000000000000000000000000000000000000000000000000000000000003b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce3b", + "value": "0x000000000000000000000000000000000000000000000000000000000000003c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce3c", + "value": "0x000000000000000000000000000000000000000000000000000000000000003d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce3d", + "value": "0x000000000000000000000000000000000000000000000000000000000000003e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce3e", + "value": "0x000000000000000000000000000000000000000000000000000000000000003f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce3f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000040" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce40", + "value": "0x0000000000000000000000000000000000000000000000000000000000000041" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce41", + "value": "0x0000000000000000000000000000000000000000000000000000000000000042" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce42", + "value": "0x0000000000000000000000000000000000000000000000000000000000000043" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce43", + "value": "0x0000000000000000000000000000000000000000000000000000000000000044" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce44", + "value": "0x0000000000000000000000000000000000000000000000000000000000000045" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce45", + "value": "0x0000000000000000000000000000000000000000000000000000000000000046" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce46", + "value": "0x0000000000000000000000000000000000000000000000000000000000000047" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce47", + "value": "0x0000000000000000000000000000000000000000000000000000000000000048" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce48", + "value": "0x0000000000000000000000000000000000000000000000000000000000000049" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce49", + "value": "0x000000000000000000000000000000000000000000000000000000000000004a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce4a", + "value": "0x000000000000000000000000000000000000000000000000000000000000004b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce4b", + "value": "0x000000000000000000000000000000000000000000000000000000000000004c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce4c", + "value": "0x000000000000000000000000000000000000000000000000000000000000004d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce4d", + "value": "0x000000000000000000000000000000000000000000000000000000000000004e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce4e", + "value": "0x000000000000000000000000000000000000000000000000000000000000004f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce4f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000050" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce50", + "value": "0x0000000000000000000000000000000000000000000000000000000000000051" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce51", + "value": "0x0000000000000000000000000000000000000000000000000000000000000052" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce52", + "value": "0x0000000000000000000000000000000000000000000000000000000000000053" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce53", + "value": "0x0000000000000000000000000000000000000000000000000000000000000054" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce54", + "value": "0x0000000000000000000000000000000000000000000000000000000000000055" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce55", + "value": "0x0000000000000000000000000000000000000000000000000000000000000056" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce56", + "value": "0x0000000000000000000000000000000000000000000000000000000000000057" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce57", + "value": "0x0000000000000000000000000000000000000000000000000000000000000058" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce58", + "value": "0x0000000000000000000000000000000000000000000000000000000000000059" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce59", + "value": "0x000000000000000000000000000000000000000000000000000000000000005a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce5a", + "value": "0x000000000000000000000000000000000000000000000000000000000000005b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce5b", + "value": "0x000000000000000000000000000000000000000000000000000000000000005c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce5c", + "value": "0x000000000000000000000000000000000000000000000000000000000000005d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce5d", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce5e", + "value": "0x000000000000000000000000000000000000000000000000000000000000005f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce5f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000060" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce60", + "value": "0x0000000000000000000000000000000000000000000000000000000000000061" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce61", + "value": "0x0000000000000000000000000000000000000000000000000000000000000062" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce62", + "value": "0x0000000000000000000000000000000000000000000000000000000000000063" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce63", + "value": "0x0000000000000000000000000000000000000000000000000000000000000064" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce64", + "value": "0x0000000000000000000000000000000000000000000000000000000000000065" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce65", + "value": "0x0000000000000000000000000000000000000000000000000000000000000066" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce66", + "value": "0x0000000000000000000000000000000000000000000000000000000000000067" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce67", + "value": "0x0000000000000000000000000000000000000000000000000000000000000068" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce68", + "value": "0x0000000000000000000000000000000000000000000000000000000000000069" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce69", + "value": "0x000000000000000000000000000000000000000000000000000000000000006a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce6a", + "value": "0x000000000000000000000000000000000000000000000000000000000000006b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce6b", + "value": "0x000000000000000000000000000000000000000000000000000000000000006c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce6c", + "value": "0x000000000000000000000000000000000000000000000000000000000000006d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce6d", + "value": "0x000000000000000000000000000000000000000000000000000000000000006e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce6e", + "value": "0x000000000000000000000000000000000000000000000000000000000000006f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce6f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000070" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce70", + "value": "0x0000000000000000000000000000000000000000000000000000000000000071" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce71", + "value": "0x0000000000000000000000000000000000000000000000000000000000000072" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce72", + "value": "0x0000000000000000000000000000000000000000000000000000000000000073" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce73", + "value": "0x0000000000000000000000000000000000000000000000000000000000000074" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce74", + "value": "0x0000000000000000000000000000000000000000000000000000000000000075" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce75", + "value": "0x0000000000000000000000000000000000000000000000000000000000000076" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce76", + "value": "0x0000000000000000000000000000000000000000000000000000000000000077" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce77", + "value": "0x0000000000000000000000000000000000000000000000000000000000000078" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce78", + "value": "0x0000000000000000000000000000000000000000000000000000000000000079" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce79", + "value": "0x000000000000000000000000000000000000000000000000000000000000007a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce7a", + "value": "0x000000000000000000000000000000000000000000000000000000000000007b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce7b", + "value": "0x000000000000000000000000000000000000000000000000000000000000007c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce7c", + "value": "0x000000000000000000000000000000000000000000000000000000000000007d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce7d", + "value": "0x000000000000000000000000000000000000000000000000000000000000007e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce7e", + "value": "0x000000000000000000000000000000000000000000000000000000000000007f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce7f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000080" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce80", + "value": "0x0000000000000000000000000000000000000000000000000000000000000081" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce81", + "value": "0x0000000000000000000000000000000000000000000000000000000000000082" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce82", + "value": "0x0000000000000000000000000000000000000000000000000000000000000083" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce83", + "value": "0x0000000000000000000000000000000000000000000000000000000000000084" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce84", + "value": "0x0000000000000000000000000000000000000000000000000000000000000085" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce85", + "value": "0x0000000000000000000000000000000000000000000000000000000000000086" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce86", + "value": "0x0000000000000000000000000000000000000000000000000000000000000087" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce87", + "value": "0x0000000000000000000000000000000000000000000000000000000000000088" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce88", + "value": "0x0000000000000000000000000000000000000000000000000000000000000089" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce89", + "value": "0x000000000000000000000000000000000000000000000000000000000000008a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce8a", + "value": "0x000000000000000000000000000000000000000000000000000000000000008b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce8b", + "value": "0x000000000000000000000000000000000000000000000000000000000000008c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce8c", + "value": "0x000000000000000000000000000000000000000000000000000000000000008d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce8d", + "value": "0x000000000000000000000000000000000000000000000000000000000000008e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce8e", + "value": "0x000000000000000000000000000000000000000000000000000000000000008f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce8f", + "value": "0x0000000000000000000000000000000000000000000000000000000000000090" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce90", + "value": "0x0000000000000000000000000000000000000000000000000000000000000091" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce91", + "value": "0x0000000000000000000000000000000000000000000000000000000000000092" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce92", + "value": "0x0000000000000000000000000000000000000000000000000000000000000093" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce93", + "value": "0x0000000000000000000000000000000000000000000000000000000000000094" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce94", + "value": "0x0000000000000000000000000000000000000000000000000000000000000095" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce95", + "value": "0x0000000000000000000000000000000000000000000000000000000000000096" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce96", + "value": "0x0000000000000000000000000000000000000000000000000000000000000097" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce97", + "value": "0x0000000000000000000000000000000000000000000000000000000000000098" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce98", + "value": "0x0000000000000000000000000000000000000000000000000000000000000099" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce99", + "value": "0x000000000000000000000000000000000000000000000000000000000000009a" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce9a", + "value": "0x000000000000000000000000000000000000000000000000000000000000009b" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce9b", + "value": "0x000000000000000000000000000000000000000000000000000000000000009c" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce9c", + "value": "0x000000000000000000000000000000000000000000000000000000000000009d" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce9d", + "value": "0x000000000000000000000000000000000000000000000000000000000000009e" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce9e", + "value": "0x000000000000000000000000000000000000000000000000000000000000009f" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce9f", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a0" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea0", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a1" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea1", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a2" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea2", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a3" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea3", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a4" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea4", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a5" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea5", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a6" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea6", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a7" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea7", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a8" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea8", + "value": "0x00000000000000000000000000000000000000000000000000000000000000a9" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcea9", + "value": "0x00000000000000000000000000000000000000000000000000000000000000aa" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceaa", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ab" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceab", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ac" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceac", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ad" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcead", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ae" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceae", + "value": "0x00000000000000000000000000000000000000000000000000000000000000af" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceaf", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b0" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb0", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b1" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb1", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b2" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb2", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b3" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb3", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b4" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb4", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b5" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb5", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b6" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb6", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b7" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb7", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b8" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb8", + "value": "0x00000000000000000000000000000000000000000000000000000000000000b9" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceb9", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ba" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceba", + "value": "0x00000000000000000000000000000000000000000000000000000000000000bb" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcebb", + "value": "0x00000000000000000000000000000000000000000000000000000000000000bc" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcebc", + "value": "0x00000000000000000000000000000000000000000000000000000000000000bd" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcebd", + "value": "0x00000000000000000000000000000000000000000000000000000000000000be" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcebe", + "value": "0x00000000000000000000000000000000000000000000000000000000000000bf" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcebf", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c0" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec0", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c1" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec1", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c2" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec2", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c3" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec3", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c4" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec4", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c5" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec5", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c6" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec6", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c7" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec7", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c8" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec8", + "value": "0x00000000000000000000000000000000000000000000000000000000000000c9" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcec9", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ca" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceca", + "value": "0x00000000000000000000000000000000000000000000000000000000000000cb" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcecb", + "value": "0x00000000000000000000000000000000000000000000000000000000000000cc" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcecc", + "value": "0x00000000000000000000000000000000000000000000000000000000000000cd" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcecd", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ce" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcece", + "value": "0x00000000000000000000000000000000000000000000000000000000000000cf" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcecf", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d0" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced0", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d1" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced1", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d2" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced2", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d3" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced3", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d4" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced4", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d5" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced5", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d6" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced6", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d7" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced7", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d8" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced8", + "value": "0x00000000000000000000000000000000000000000000000000000000000000d9" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fced9", + "value": "0x00000000000000000000000000000000000000000000000000000000000000da" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceda", + "value": "0x00000000000000000000000000000000000000000000000000000000000000db" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcedb", + "value": "0x00000000000000000000000000000000000000000000000000000000000000dc" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcedc", + "value": "0x00000000000000000000000000000000000000000000000000000000000000dd" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcedd", + "value": "0x00000000000000000000000000000000000000000000000000000000000000de" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcede", + "value": "0x00000000000000000000000000000000000000000000000000000000000000df" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcedf", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e0" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee0", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e1" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee1", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e2" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee2", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e3" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee3", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e4" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee4", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e5" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee5", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e6" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee6", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e7" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee7", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e8" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee8", + "value": "0x00000000000000000000000000000000000000000000000000000000000000e9" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcee9", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ea" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceea", + "value": "0x00000000000000000000000000000000000000000000000000000000000000eb" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceeb", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ec" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceec", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ed" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceed", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ee" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceee", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ef" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceef", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f0" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef0", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f1" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef1", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f2" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef2", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f3" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef3", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f4" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef4", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f5" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef5", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f6" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef6", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f7" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef7", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f8" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef8", + "value": "0x00000000000000000000000000000000000000000000000000000000000000f9" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcef9", + "value": "0x00000000000000000000000000000000000000000000000000000000000000fa" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcefa", + "value": "0x00000000000000000000000000000000000000000000000000000000000000fb" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcefb", + "value": "0x00000000000000000000000000000000000000000000000000000000000000fc" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcefc", + "value": "0x00000000000000000000000000000000000000000000000000000000000000fd" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcefd", + "value": "0x00000000000000000000000000000000000000000000000000000000000000fe" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fcefe", + "value": "0x00000000000000000000000000000000000000000000000000000000000000ff" + }, + { + "key": "0x004c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fceff", + "value": "0x0000000000000000000000000000000000000000000000000000000000000100" + } + ], + "root": "0x090ef773023b99997f3c8c72e3fa1fbd9b0b62833ffb662e457b60530b358721" + } + ], + "sequence_vectors": [ + { + "seed": 8297, + "ops": [ + { + "op": "set", + "key": "0x0015c4813bb5b1499e0a7e0d46daab8e10c70940c008c566d5c68557b1523d1f2999", + "value": "0x00000000000000000000000000000000000000000000000000000000362952bd" + }, + { + "op": "set", + "key": "0xff2eb90959edcd0b6a8a7922b8b8d37cd9664001c217b4c8e6d2df4b8ede329a7191c71427661efcc7f991940e68aae22bede52be877c5e2b9b4e0c22051e2c61706", + "value": "0x000000000000000000000000000000000000000000000000000000005912e971" + }, + { + "op": "delete", + "key": "0xff2eb90959edcd0b6a8a7922b8b8d37cd9664001c217b4c8e6d2df4b8ede329a7191c71427661efcc7f991940e68aae22bede52be877c5e2b9b4e0c22051e2c61706" + }, + { + "op": "set", + "key": "0xff9c847c439d6431d36ddeb326cb553da2e967778a24afbd1d23a62412186faf016d21858cec8b9c323663e513206217295d0693bb4eb042fd7e5e863612ec45fe5c", + "value": "0x000000000000000000000000000000000000000000000000000000009e92aea6" + }, + { + "op": "set", + "key": "0x00998f08d7054351e5fa3ccadda32ab8d6c0c86a825dd360d57c0fe6b46612297c21", + "value": "0x0000000000000000000000000000000000000000000000000000000037f3974d" + }, + { + "op": "set", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec8516", + "value": "0x0000000000000000000000000000000000000000000000000000000091546180" + }, + { + "op": "set", + "key": "0xffd1afd6427b13f2ce7beb82a2cd6bbfc58eac8b2d46bc87bfbc78fbf440e18a635bd91a1bb3fea09cd07c1f6f0b5b0928ec465c3d5409c3dc4d9f3b384fd276b0df", + "value": "0x00000000000000000000000000000000000000000000000000000000d560d2d0" + }, + { + "op": "set", + "key": "0x002eb90959edcd0b6a8a7922b8b8d37cd9664001c217b4c8e6d2df4b8ede329a717c", + "value": "0x00000000000000000000000000000000000000000000000000000000b7e649ff" + }, + { + "op": "delete", + "key": "0xff9c847c439d6431d36ddeb326cb553da2e967778a24afbd1d23a62412186faf016d21858cec8b9c323663e513206217295d0693bb4eb042fd7e5e863612ec45fe5c" + }, + { + "op": "set", + "key": "0xffe60d91d1d11837912311124089c6b73c9d726c99f4df797eb48c2b7fec6e5e2cedb6b011186812600d2c38950eb3aa1e9e39d11456e96ac38d3ec9cb37e4816783", + "value": "0x0000000000000000000000000000000000000000000000000000000015716296" + }, + { + "op": "set", + "key": "0x0078a2812688114caf4452a5611322977e4b1000e59ef408d4c8e2c90028370bcbf3", + "value": "0x00000000000000000000000000000000000000000000000000000000d566656c" + }, + { + "op": "set", + "key": "0x000861030791246737d706c0d0017de01a26278bbe4ce8a134f5e0cd6f7eb69bcf6b", + "value": "0x00000000000000000000000000000000000000000000000000000000c6f30fd3" + }, + { + "op": "set", + "key": "0x0068e867f5f899fc01d641a00cea2c42d58f3cc061797135198a85301d1e2903062a", + "value": "0x00000000000000000000000000000000000000000000000000000000308a8072" + }, + { + "op": "set", + "key": "0xff46aeb3243f905d4e0eac7f60ee8962848c87e79dfbd019ece14fe45bd720ea6bc7d37b238b059b3f39fb34f03f189fc8c596ad3bc45f7231d1a8e723510074014d", + "value": "0x00000000000000000000000000000000000000000000000000000000fd6c27f9" + }, + { + "op": "set", + "key": "0x00a43fb2d7beb12b34786a837467f62b7d6c5dab4ab7eecc89c0b67db76a682927c1", + "value": "0x000000000000000000000000000000000000000000000000000000000b5daa14" + }, + { + "op": "set", + "key": "0x00543907253acb230f5dc96bbafd8ea0f37dd63b30e8a4a3675bb007673a31fbc5d7", + "value": "0x00000000000000000000000000000000000000000000000000000000ef86c437" + }, + { + "op": "set", + "key": "0xff0fb23a930b05e3bdfbdd0e6694fa9df9c5059ceabc60618bc40931ddb379f1f64c1111b166c73d061477381d77db7818b678c5a5595df51af307fccfda674238c3", + "value": "0x000000000000000000000000000000000000000000000000000000008687ece2" + }, + { + "op": "set", + "key": "0x0046aeb3243f905d4e0eac7f60ee8962848c87e79dfbd019ece14fe45bd720ea6b69", + "value": "0x000000000000000000000000000000000000000000000000000000008d81d15d" + }, + { + "op": "set", + "key": "0xff998f08d7054351e5fa3ccadda32ab8d6c0c86a825dd360d57c0fe6b46612297c216fd3a73b07a45409a19b5e6d545779e55b6900fddc4443dcb06e3d97e25e1908", + "value": "0x000000000000000000000000000000000000000000000000000000008f91e546" + }, + { + "op": "set", + "key": "0x002cebacb183e2f51a3b6cb9f7fe2bd11c399bf1bdb8ce95ed457d3e46190197a0fd", + "value": "0x00000000000000000000000000000000000000000000000000000000b638fa76" + } + ], + "roots_after": [ + "0xcf6ca0dad8d69c45120da214b9052e614649a640e52560d0f47487073325e93d", + "0xa7247b2d9c8c6e49a18add897d235a5e72c292a5144008676972983ebd5e624e", + "0xcf6ca0dad8d69c45120da214b9052e614649a640e52560d0f47487073325e93d", + "0x9b659884c441cf90291fe8e2336c65d34cb0da60709da8a6165486c1a00829e4", + "0x1b5adc55d232b1c9b93c8bae8c1e736a2e596efabd3cc466de195d38ecbe9746", + "0xa604a44382e79cbca1ae6140d150d84980510447609e2a2356dce852861b3217", + "0x67994f2a47248a4677f1691706ec0ad76d479894fb7015f06bda6d4af4d46a55", + "0x99b2ce17b9b779b3f63f68d218182a59b928c80c1ac8d8bcb716cd3c202a20b3", + "0xda86ba5028d29db7d6006f7cf340f6af5203506cbcc76e72ba27302933a4943f", + "0xaa845405281cd62dc569bf03f8a6c1bd35609cd2c1860db56a9478254f8cb25e", + "0x1708bbd96a838a1f0816667f74a2d2a1a01b8c5499bcb533be4a45cf684b8bef", + "0xf8850e79c7adbebb051eb0ea11e3ca9866c11f660cb2b7d2c3892beb20246f26", + "0xc6c221bffc3947d15ad0e994e150fa0e654897bde5039e88bcda287cf24dc827", + "0xf1036ba9ebb2780554ab2596581122a999fe301b33446a3bf13c623dd8796f96", + "0x61cd8daf48607b812e063019741d2ae0d77bf9193b2b5f5996b756a22a488b1f", + "0x73aaf9a0a5b4f179bacf1f1c2fbcb630163797d02cdc5115977d26e8ea9f48ff", + "0xff83d59bd08841abbfc5cf10620dd334ec2fbb9bd0e579aec30905b4e40ccab9", + "0x44b55ce7592e1ba9aa3ea9c465cdadee3f7e09abccbec49aec6290eab112d0a4", + "0xe1d9ef033ebdf35275483848038f858fa43063805c1cc1b5ea96654745cc3b94", + "0xcff0d627850d4d4ea368e66ff2349c0caf454129e387d23df3c8dcf71534768f" + ] + }, + { + "seed": 11832, + "ops": [ + { + "op": "set", + "key": "0xff1df77729bb76b139342cfc86aff011590bf6bb810f47e69a6babac30af96f9f5ffa1e869c4566231d09357daec90e71aa93821a7347b29175ef4a1bc02a0b38120", + "value": "0x0000000000000000000000000000000000000000000000000000000079b57838" + }, + { + "op": "set", + "key": "0xff3cbfd03e3f083b0987f841c4372031faf655a63472518a3613e2e30ecb542b51ef2c19e3ed459de7a67968b7004609b54e8e34fef08d7f9ed3c6eedcc980488255", + "value": "0x00000000000000000000000000000000000000000000000000000000449c8b5d" + }, + { + "op": "set", + "key": "0x007b1eefd69ea014244937aaeaea4e8d4d296c04edd75ae47897e5612ef5a1a25289", + "value": "0x00000000000000000000000000000000000000000000000000000000b5b13d29" + }, + { + "op": "set", + "key": "0xffa43fb2d7beb12b34786a837467f62b7d6c5dab4ab7eecc89c0b67db76a6829272094bf023796a3d4bdc16643cddf5395ab048ba346a4f50f1cc64da78ad7dd5266", + "value": "0x000000000000000000000000000000000000000000000000000000008cc69019" + }, + { + "op": "set", + "key": "0xff5963897639cd11d686b199366a1249d76bc89e0f7161c9eca48d9a199435952d4a28b184a4e022d38844c7967da6fbdf879ffafc6cfabf88c929ec9c9ff90040d1", + "value": "0x00000000000000000000000000000000000000000000000000000000af9bbd7d" + }, + { + "op": "set", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec85bf", + "value": "0x000000000000000000000000000000000000000000000000000000005dde837c" + }, + { + "op": "delete", + "key": "0xffa43fb2d7beb12b34786a837467f62b7d6c5dab4ab7eecc89c0b67db76a6829272094bf023796a3d4bdc16643cddf5395ab048ba346a4f50f1cc64da78ad7dd5266" + }, + { + "op": "delete", + "key": "0xff1df77729bb76b139342cfc86aff011590bf6bb810f47e69a6babac30af96f9f5ffa1e869c4566231d09357daec90e71aa93821a7347b29175ef4a1bc02a0b38120" + }, + { + "op": "delete", + "key": "0xff3cbfd03e3f083b0987f841c4372031faf655a63472518a3613e2e30ecb542b51ef2c19e3ed459de7a67968b7004609b54e8e34fef08d7f9ed3c6eedcc980488255" + }, + { + "op": "delete", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec85bf" + }, + { + "op": "set", + "key": "0xff0fb23a930b05e3bdfbdd0e6694fa9df9c5059ceabc60618bc40931ddb379f1f6065b90a89bb317ff71b1f933493a08b55f152abe9f5ae5b58400664d141c512119", + "value": "0x000000000000000000000000000000000000000000000000000000000a082d85" + }, + { + "op": "set", + "key": "0xff998f08d7054351e5fa3ccadda32ab8d6c0c86a825dd360d57c0fe6b46612297c725184c1ce99f155151a19aa14a427e1a784778319e2b193ac66bd6374b328cdff", + "value": "0x00000000000000000000000000000000000000000000000000000000a3ea3eb4" + }, + { + "op": "set", + "key": "0xff78a2812688114caf4452a5611322977e4b1000e59ef408d4c8e2c90028370bcb71e2e06bd4204550cd67cae560e42f8afcdc75e454a14f00ed2a8ec7c885869e40", + "value": "0x000000000000000000000000000000000000000000000000000000007435a9e4" + }, + { + "op": "delete", + "key": "0xff0fb23a930b05e3bdfbdd0e6694fa9df9c5059ceabc60618bc40931ddb379f1f6065b90a89bb317ff71b1f933493a08b55f152abe9f5ae5b58400664d141c512119" + }, + { + "op": "set", + "key": "0x000fb23a930b05e3bdfbdd0e6694fa9df9c5059ceabc60618bc40931ddb379f1f668", + "value": "0x000000000000000000000000000000000000000000000000000000000275abc8" + }, + { + "op": "delete", + "key": "0xff5963897639cd11d686b199366a1249d76bc89e0f7161c9eca48d9a199435952d4a28b184a4e022d38844c7967da6fbdf879ffafc6cfabf88c929ec9c9ff90040d1" + }, + { + "op": "set", + "key": "0x007293ce391f5cd652216ca8ea31069a06a82f393028dfc8786aa5efd7be734b2cbe", + "value": "0x0000000000000000000000000000000000000000000000000000000094f87f55" + }, + { + "op": "set", + "key": "0xff4c3a8541d9ec369a9fd4bcdad885cc3b166da70ed6a83066924727d9ff147fce963472d01342d91a0a268bdfee2ecf01a6d30f9021151faf2e2e6719120bf40f0a", + "value": "0x00000000000000000000000000000000000000000000000000000000fdac9fff" + }, + { + "op": "set", + "key": "0xff1df77729bb76b139342cfc86aff011590bf6bb810f47e69a6babac30af96f9f5ffa1e869c4566231d09357daec90e71aa93821a7347b29175ef4a1bc02a0b38181", + "value": "0x00000000000000000000000000000000000000000000000000000000e4d876b8" + }, + { + "op": "set", + "key": "0x00e2f41518226671332da4adddab1fdcb9903c7b895c7943b669067fe96b0baacfd5", + "value": "0x0000000000000000000000000000000000000000000000000000000019be8821" + } + ], + "roots_after": [ + "0x5cdba549646b7612059692efa58d33f90059dfa9df06433f4438510c1b32e049", + "0x6ae6cd7fe0f7f9dfbff03d1d6b88c55f8c9f247584c70427a2a53612cdf202e6", + "0xcbe8e97d34728b819654a46096d4fe13abcdeed3e31cf088be21de915051259a", + "0x155969c29e159077b8a9997325ac9962fee077d64c9467f1ccd68618c9abbcdb", + "0x2439229c4decda037eb0a692540821cdd6c77df8ba51d478291d905e5de4f5e4", + "0xc252ede6498166ca351dc0346fdd1ee5df6694e3acf7f5714f2e80db687ccc79", + "0x7fcfd5973d297879b65209bb9cf1ca74c30ee717e13350d2c0447675165cb80b", + "0xdbb4e48dda8ae1f9287abccd62637085bb171f5897ad42e5019500209e89202d", + "0x14712da3d0ce63185cae268f29bf2593295476a83a8e95b3b4daf01a83274d6f", + "0x51098829074dc7618bdca7c079f0ac0577e0a08a0df5c92dcd104bf6cad43ea6", + "0x2643869090c9ca916157631cac68996dd01de67c66639eeb443f9b93ebe7df1c", + "0x9793adae9d275d569398dc631e09da40464320a0dc0c644d36d79f97d34b7288", + "0xef2bd1092bc3404855dd94e9452f97cebeb425d8027e7a56dc1c5fc0e93c38f6", + "0x85170f6f2bed9f200e8835f9d2c9bd13cb9267d77e834dcd73d43d1f897cc346", + "0x71c46fa0b2804adc2012db0d399c477429071b47f13fc31ddc125bc599e8fafb", + "0xeba1b27a56ff60fd186ede1c5ee445c59b2733f527c6ccc30b765b6917491720", + "0x2d0dcb278bc7892ad37afe2c523b73d18a60e76b1dc41d04469896c3b355d490", + "0x5aadbab832910092a62a49e78c5d76b10ffe26f3452a0bd47fd049e36eb31d63", + "0x53b8d1dab39520c882f5666b092ab47f59f4f3084c9471eefbb15d6a0c58ad5d", + "0x52ff67405b74b808b1649b64764de113df17e70ea5aebf5489bc1db7aad52422" + ] + }, + { + "seed": 3102, + "ops": [ + { + "op": "set", + "key": "0xff2cebacb183e2f51a3b6cb9f7fe2bd11c399bf1bdb8ce95ed457d3e46190197a02a6a55293936c1e1b6894fbb3b2873c993bd580ec835889bf0f211410e368fc199", + "value": "0x000000000000000000000000000000000000000000000000000000002e422f9a" + }, + { + "op": "delete", + "key": "0xff2cebacb183e2f51a3b6cb9f7fe2bd11c399bf1bdb8ce95ed457d3e46190197a02a6a55293936c1e1b6894fbb3b2873c993bd580ec835889bf0f211410e368fc199" + }, + { + "op": "set", + "key": "0xff29e4e6bbd168fe5b46b7d34c34b628f95d54c4e75f9335a7e96282d1173b07dfd0f9925799d37553e0f34b2d7a49a135fc34a90b6fb99bfe70f2e43c28e758854d", + "value": "0x000000000000000000000000000000000000000000000000000000002ecaa733" + }, + { + "op": "set", + "key": "0x00d1afd6427b13f2ce7beb82a2cd6bbfc58eac8b2d46bc87bfbc78fbf440e18a635f", + "value": "0x0000000000000000000000000000000000000000000000000000000076fe3750" + }, + { + "op": "set", + "key": "0x000861030791246737d706c0d0017de01a26278bbe4ce8a134f5e0cd6f7eb69bcff9", + "value": "0x0000000000000000000000000000000000000000000000000000000035fd5ae2" + }, + { + "op": "set", + "key": "0xff998f08d7054351e5fa3ccadda32ab8d6c0c86a825dd360d57c0fe6b46612297cc290a8b5ac7a453c9cec5724a8a85ad4002fb6dc9c73e86aedfa05133317c0402e", + "value": "0x00000000000000000000000000000000000000000000000000000000be9e2390" + }, + { + "op": "set", + "key": "0x005963897639cd11d686b199366a1249d76bc89e0f7161c9eca48d9a199435952d33", + "value": "0x00000000000000000000000000000000000000000000000000000000b3e90b26" + }, + { + "op": "set", + "key": "0xffe57860ba0adcdd1294caf74da8b990a80a8a6861bc01b30dfe07450d25bab38f20483a0da833c6de846c07a8214bee6ee90bd1da5d9b9553f74784602fdd83dfb9", + "value": "0x0000000000000000000000000000000000000000000000000000000051dcd3af" + }, + { + "op": "set", + "key": "0x00e6b6d8d98be54d4c00cee378bedf964f755b5257bf1eabf52cf39aa987686fa743", + "value": "0x0000000000000000000000000000000000000000000000000000000083a3dad3" + }, + { + "op": "set", + "key": "0xff0fb23a930b05e3bdfbdd0e6694fa9df9c5059ceabc60618bc40931ddb379f1f6065b90a89bb317ff71b1f933493a08b55f152abe9f5ae5b58400664d141c512161", + "value": "0x00000000000000000000000000000000000000000000000000000000939e31a5" + }, + { + "op": "set", + "key": "0xff2cebacb183e2f51a3b6cb9f7fe2bd11c399bf1bdb8ce95ed457d3e46190197a018eb952465e3dd252f35c719bb6d7d14f62bf363e0afa74100d3046f81539e08b9", + "value": "0x000000000000000000000000000000000000000000000000000000002da16542" + }, + { + "op": "set", + "key": "0xff4b83f061efb2f4708114e46b5ef50a255528c28c80bbe5fc6ad88e892bd4c4ea37491f84eb77d352d7d8fca0484b856ceab7236e2f773d157b3fbd6d0586de7f48", + "value": "0x000000000000000000000000000000000000000000000000000000003b1510f6" + }, + { + "op": "set", + "key": "0x00c901cfbb39e551189323bea0af6cf9e81008c90dce65cbc2ff4672e263d7bb87f3", + "value": "0x0000000000000000000000000000000000000000000000000000000087d0f3c4" + }, + { + "op": "set", + "key": "0x0055a6923f7414ba482ae8a64b407f386a0bb0491b742bd4b22b5b4b44fca84e848f", + "value": "0x000000000000000000000000000000000000000000000000000000008cfbc63e" + }, + { + "op": "set", + "key": "0xff7b1eefd69ea014244937aaeaea4e8d4d296c04edd75ae47897e5612ef5a1a25254d51e5b726c0f3cfd1b03f07f25907b18767b99fef9eb727475858c458e1c7087", + "value": "0x00000000000000000000000000000000000000000000000000000000af70ae1b" + }, + { + "op": "set", + "key": "0xff29e4e6bbd168fe5b46b7d34c34b628f95d54c4e75f9335a7e96282d1173b07df754b0f807e1dc46306d4cb12e85e18d008678fa80cd9e6fa118c34e179bd412a87", + "value": "0x00000000000000000000000000000000000000000000000000000000d15c3b16" + }, + { + "op": "set", + "key": "0xff29e4e6bbd168fe5b46b7d34c34b628f95d54c4e75f9335a7e96282d1173b07df754b0f807e1dc46306d4cb12e85e18d008678fa80cd9e6fa118c34e179bd412a57", + "value": "0x000000000000000000000000000000000000000000000000000000003e5f6e17" + }, + { + "op": "set", + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e4b6", + "value": "0x000000000000000000000000000000000000000000000000000000002a25f39d" + }, + { + "op": "delete", + "key": "0xff0fb23a930b05e3bdfbdd0e6694fa9df9c5059ceabc60618bc40931ddb379f1f6065b90a89bb317ff71b1f933493a08b55f152abe9f5ae5b58400664d141c512161" + }, + { + "op": "set", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec85de", + "value": "0x00000000000000000000000000000000000000000000000000000000fd3f724c" + } + ], + "roots_after": [ + "0xdd16f2b8e1c012d8ed988df1cd9e31fc06bceee33304ec90fdee4c9e0d6f2522", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0xe7c887160ebfbf178105a04157a5b67d669ac6899fbf632526152fc5cb82cd80", + "0xd77cc71cfc52b7a272f47bb93cf6e3b5ae4a2d7fc991e4e2b76586018620570b", + "0xfb180e479837451381e4ebbf7a21be6937dbab9b830eeb798cf1854b48bc82ad", + "0x663475566fe0b6afa6fb71c73802d98b37948ccc16237229d8722a66b31c48f0", + "0xe26dac98c3f044695b7bda1e7bab4e9e0422d41b367b3fb873d50734d15f8de8", + "0x90263ca13708230b2ae2d6413b76995a7c177ed239310a9628fe4aac1e2d51fe", + "0xd7893e542d99ae8c18bd7a495190a58e2e1e25252d4fa9378ef2694db0e751ee", + "0x4df5042f26d44caf9d655bda9bb46284498628761474d302392daff333e5b610", + "0x1113ff21db5505e243f390d5df0fc11549690813ff7a25e8bc60b5209d39ed72", + "0xe14995d380c1552d2901cacc440b51fed58732d5eb6b6adb202e891f3f53b6d6", + "0x5d3fd815f8282caf95aab8105212fb98b6f5516beb9fba2ed4f4ee502b85ed74", + "0x09b380d8286220693a4a9e4b85e074ea94cf0dadbccacd05d6f8dc5470438087", + "0x957d9940952d058f2523c52603f8cc27537ee0e40b8c452d9691e28ff0f9d782", + "0x7cc8b1cbe418de642947381e3fd9251730dff6165bb7d48de4388cce893d8aea", + "0x5ea579760671e854abc2adfcb037bd6f1470c26e5f4ba6e9392d05e1af830b6d", + "0xb4281adb36825d1f7dd6b7f0536a81ad9950fb727124828f0a94a79c32885052", + "0xa90bdbe39f8833c02f914c2ca37a5d5b91be83ba6a4721923b1ffe8899c40d9e", + "0x8c3fff51ed5749c6147c9bb35b5ac0f20f0f0ff92604b0d7cd0f83bcaecf52b4" + ] + }, + { + "seed": 90210, + "ops": [ + { + "op": "set", + "key": "0xffd1afd6427b13f2ce7beb82a2cd6bbfc58eac8b2d46bc87bfbc78fbf440e18a63aeb0aca39c8f6ba440835a8e371b8b2719dec5b24a57f1da0e7519c3cd74beff52", + "value": "0x00000000000000000000000000000000000000000000000000000000cec06895" + }, + { + "op": "set", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec8587", + "value": "0x0000000000000000000000000000000000000000000000000000000026a125de" + }, + { + "op": "delete", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec8587" + }, + { + "op": "delete", + "key": "0xffd1afd6427b13f2ce7beb82a2cd6bbfc58eac8b2d46bc87bfbc78fbf440e18a63aeb0aca39c8f6ba440835a8e371b8b2719dec5b24a57f1da0e7519c3cd74beff52" + }, + { + "op": "set", + "key": "0x00c901cfbb39e551189323bea0af6cf9e81008c90dce65cbc2ff4672e263d7bb871a", + "value": "0x0000000000000000000000000000000000000000000000000000000038f9aacc" + }, + { + "op": "set", + "key": "0x005963897639cd11d686b199366a1249d76bc89e0f7161c9eca48d9a199435952d0f", + "value": "0x0000000000000000000000000000000000000000000000000000000053b3bca6" + }, + { + "op": "set", + "key": "0x0068e867f5f899fc01d641a00cea2c42d58f3cc061797135198a85301d1e290306c3", + "value": "0x0000000000000000000000000000000000000000000000000000000058e273d9" + }, + { + "op": "set", + "key": "0x00e61994d31ceae39cae5feb02303eb90e0d4ae90f5b0755c3c26adc81f360ec85b3", + "value": "0x000000000000000000000000000000000000000000000000000000008debe84f" + }, + { + "op": "delete", + "key": "0x00c901cfbb39e551189323bea0af6cf9e81008c90dce65cbc2ff4672e263d7bb871a" + }, + { + "op": "set", + "key": "0x0055bbfb5b05f80543bd662a7b7a74980a2992612798087fa24e1fc86b8e8508e4b2", + "value": "0x0000000000000000000000000000000000000000000000000000000052fbeee9" + }, + { + "op": "set", + "key": "0xff68e867f5f899fc01d641a00cea2c42d58f3cc061797135198a85301d1e290306efdc84373704529ffcbc3d4ed318de0ec2cdf5f61dd800c143f44a741af2f838a0", + "value": "0x0000000000000000000000000000000000000000000000000000000012acb6e5" + }, + { + "op": "set", + "key": "0xffe60d91d1d11837912311124089c6b73c9d726c99f4df797eb48c2b7fec6e5e2c3b9034b692f2597d8c9c9ad6921ecd112d2f69cc50a6e065034e2ace540e8928ab", + "value": "0x0000000000000000000000000000000000000000000000000000000088a67fe9" + }, + { + "op": "set", + "key": "0xffe60d91d1d11837912311124089c6b73c9d726c99f4df797eb48c2b7fec6e5e2c9c921b846417391ff7e9525580b53876b30e3f6cf563e5849525f6fa88bf230f1a", + "value": "0x0000000000000000000000000000000000000000000000000000000075b67af1" + }, + { + "op": "set", + "key": "0xff78a2812688114caf4452a5611322977e4b1000e59ef408d4c8e2c90028370bcb279b454b0f4a6026943322996543a3cbb6c41d7c467067d9bc010242feef3c2c4b", + "value": "0x00000000000000000000000000000000000000000000000000000000fd6d065d" + }, + { + "op": "set", + "key": "0x00bded9122e6171a5dad9ce2aaebd933abf4718e9bf12448cf113064c9fd8e6ad0a4", + "value": "0x000000000000000000000000000000000000000000000000000000001c92d573" + }, + { + "op": "delete", + "key": "0xffe60d91d1d11837912311124089c6b73c9d726c99f4df797eb48c2b7fec6e5e2c9c921b846417391ff7e9525580b53876b30e3f6cf563e5849525f6fa88bf230f1a" + }, + { + "op": "set", + "key": "0x003cbfd03e3f083b0987f841c4372031faf655a63472518a3613e2e30ecb542b514a", + "value": "0x00000000000000000000000000000000000000000000000000000000617ad32c" + }, + { + "op": "set", + "key": "0xffec64b2de1fc1d53b795f49a1c84ca4172bc3292472c3e244e4f8c225fa786f56b7bce92ed9ae70f9ca2c10d21fe857f18cd2b64d54c70193d8a0dd772967c2580d", + "value": "0x00000000000000000000000000000000000000000000000000000000565e29f9" + }, + { + "op": "delete", + "key": "0xff78a2812688114caf4452a5611322977e4b1000e59ef408d4c8e2c90028370bcb279b454b0f4a6026943322996543a3cbb6c41d7c467067d9bc010242feef3c2c4b" + }, + { + "op": "set", + "key": "0xffd1afd6427b13f2ce7beb82a2cd6bbfc58eac8b2d46bc87bfbc78fbf440e18a635bd91a1bb3fea09cd07c1f6f0b5b0928ec465c3d5409c3dc4d9f3b384fd276b0e3", + "value": "0x00000000000000000000000000000000000000000000000000000000f03eb650" + } + ], + "roots_after": [ + "0xe4a4cc81bad5ec263e1470b6766f0e48c44cb6de9edfafe42ce6a742e7c0de35", + "0x628d4752f5a37f73d798da0fd84216c8c96d376e81ca8c011a1ef0e5b293037d", + "0xe4a4cc81bad5ec263e1470b6766f0e48c44cb6de9edfafe42ce6a742e7c0de35", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x36d0ac2cbaa8daac84e3abb0711753ab2803d39fb7def6e34b95e607b5177c92", + "0xcdd2d3edb22a14874aa1b8adc01bad8e80dd4f6f0c603788bd28705981409ba7", + "0x0438e8ad308f977fe36353cde64fef7f1210dd3820b5275c3fffe712e342f125", + "0xe8514dc5fbf6794122aad2a058ad95df0835731e49061dfc5238fe8673b7cb57", + "0xfa1bed5d0c8187d176cdabee2a0b6d96ec72791326c1d9d00555e2752fa4ae06", + "0x388f561f8cd9b274f5c9cd3154ab49c7586c9cae7b28c0b7471be9be98cba3be", + "0x36fca86e1497e245753b5662e45cac10ce68278494adef2b1219b534d63411e2", + "0x98ebe09efe3ff35ee2f8b20732a3f30408119a25e620107c33f503cd39e55143", + "0xcd53e141a34d3c96464d66bc027b4cc0731739f1e645aced12c51072463f8fc4", + "0xbba122989fb7ec24899f02a1aa37fcf967d84f6c5207d843ac9a754b1eaa7ba3", + "0xb219a14dd80c8cc69152edbcec566fe773892d5e615e310b063e99def0f6aa79", + "0x3531deb75570f3a160ba9b471a10be04750c59ed54984186a46d4521e5058145", + "0xd3e835c31b1c00c8f864d7da97f035c6147452c3762a7f69b174751b815737a8", + "0xd74280c2db61bd2bd9d739ae4c38053ce6192f683907b95c6c0fed0ddf6b1b66", + "0x24a2abd1e5f37cbb1e104406be5ee8b6c4989c27bb6ec117ae4e18c0b66dfc9c", + "0x3a7c4099580d9b766538946c88846c95d7822fd4d6121a20e57e1791026600f5" + ] + }, + { + "seed": 20260727, + "ops": [ + { + "op": "set", + "key": "0x002eb90959edcd0b6a8a7922b8b8d37cd9664001c217b4c8e6d2df4b8ede329a7170", + "value": "0x0000000000000000000000000000000000000000000000000000000068535e9a" + }, + { + "op": "set", + "key": "0xff3cbfd03e3f083b0987f841c4372031faf655a63472518a3613e2e30ecb542b5111589c57f4d8ad55d2c4ece4c6b63961c45be7479bfd08d04fab2d26d0acb7a091", + "value": "0x0000000000000000000000000000000000000000000000000000000056756dfe" + }, + { + "op": "set", + "key": "0x007b1eefd69ea014244937aaeaea4e8d4d296c04edd75ae47897e5612ef5a1a2521a", + "value": "0x000000000000000000000000000000000000000000000000000000005959a793" + }, + { + "op": "delete", + "key": "0x002eb90959edcd0b6a8a7922b8b8d37cd9664001c217b4c8e6d2df4b8ede329a7170" + }, + { + "op": "delete", + "key": "0x007b1eefd69ea014244937aaeaea4e8d4d296c04edd75ae47897e5612ef5a1a2521a" + }, + { + "op": "set", + "key": "0x0055a6923f7414ba482ae8a64b407f386a0bb0491b742bd4b22b5b4b44fca84e8468", + "value": "0x000000000000000000000000000000000000000000000000000000003c2b7202" + }, + { + "op": "delete", + "key": "0xff3cbfd03e3f083b0987f841c4372031faf655a63472518a3613e2e30ecb542b5111589c57f4d8ad55d2c4ece4c6b63961c45be7479bfd08d04fab2d26d0acb7a091" + }, + { + "op": "delete", + "key": "0x0055a6923f7414ba482ae8a64b407f386a0bb0491b742bd4b22b5b4b44fca84e8468" + }, + { + "op": "set", + "key": "0xffe60d91d1d11837912311124089c6b73c9d726c99f4df797eb48c2b7fec6e5e2c6066e8c722a574a900a59551ed956d32ca2b36e48771ed8b373da6b223bbbe9c8f", + "value": "0x000000000000000000000000000000000000000000000000000000009bb7df73" + }, + { + "op": "set", + "key": "0x009c847c439d6431d36ddeb326cb553da2e967778a24afbd1d23a62412186faf0130", + "value": "0x00000000000000000000000000000000000000000000000000000000def11b80" + }, + { + "op": "set", + "key": "0x007b1eefd69ea014244937aaeaea4e8d4d296c04edd75ae47897e5612ef5a1a2524d", + "value": "0x00000000000000000000000000000000000000000000000000000000f05708e7" + }, + { + "op": "set", + "key": "0x00998f08d7054351e5fa3ccadda32ab8d6c0c86a825dd360d57c0fe6b46612297cba", + "value": "0x00000000000000000000000000000000000000000000000000000000c433224b" + }, + { + "op": "set", + "key": "0xff5ac76a8f23131e4ca988c8afcc313130cc95e521d2691635fc26196d63e66a1f33b1e44125772918b9d44a12766eb0dad773e863d120780173c394789aa668e925", + "value": "0x00000000000000000000000000000000000000000000000000000000abbc594e" + }, + { + "op": "set", + "key": "0xff998f08d7054351e5fa3ccadda32ab8d6c0c86a825dd360d57c0fe6b46612297c36c8cce2938e709d43205827ddebecc780d7e631bb1acb24046af6e5b23e445aa2", + "value": "0x00000000000000000000000000000000000000000000000000000000219ea23a" + }, + { + "op": "set", + "key": "0x00d1d141c1bed8bb4f809292e9340a0209cd167484a24a5ca993fcac4b97086d89af", + "value": "0x00000000000000000000000000000000000000000000000000000000e015951e" + }, + { + "op": "delete", + "key": "0x00d1d141c1bed8bb4f809292e9340a0209cd167484a24a5ca993fcac4b97086d89af" + }, + { + "op": "set", + "key": "0xffc28c21982a56a6cecf595bfeb78297c7432abfb487cc75c09db2856a118f441df2a84c02b0cbaafcabc4cbb5f988d916f3287032f07e3f8bf51588b4311ed04c72", + "value": "0x00000000000000000000000000000000000000000000000000000000973ab40a" + }, + { + "op": "set", + "key": "0x00e2f41518226671332da4adddab1fdcb9903c7b895c7943b669067fe96b0baacfdb", + "value": "0x000000000000000000000000000000000000000000000000000000000c8a8e64" + }, + { + "op": "delete", + "key": "0x007b1eefd69ea014244937aaeaea4e8d4d296c04edd75ae47897e5612ef5a1a2524d" + }, + { + "op": "set", + "key": "0xff15c4813bb5b1499e0a7e0d46daab8e10c70940c008c566d5c68557b1523d1f29cac055e3c06fef0ce191be15524a246c11d1103158d4f8b49d31c629e552005366", + "value": "0x000000000000000000000000000000000000000000000000000000001a5e6148" + } + ], + "roots_after": [ + "0x8a7b32f0179f8591d8d29bbc5ce478d2d1cca5ceebddb5c6880e6682fd1a7458", + "0x63e670849972d58347a67cb04d30b369145ea59ac06fd411c231b46c05b534c8", + "0xeedfc71a50202763dc37c74f994b52783a5e3ac19ff7c3d818c9bd2c95b562f7", + "0x1cfbe1c8b87bb9ecafd506ee003ddaeef688eaa24a564cd7147270130425709e", + "0xf8daed5ef73e34987561ea33d980ed96600f48700aa75ea5706c74f79babdb3d", + "0xcf348977fb91b672c061404e5bc6f063a444d19478b4aba7450531d48ae5b8f8", + "0xfa4116de24b5dce1e1ebb9736c78a00ec1eb957aaee7e9f9af3b74f68e43a73e", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x5f2fc0f08e034ab42e6b4b8eec8038618707b8f2ae9722e14a6048d1935a438c", + "0xbc0812ac0dbf193721b4e90a5f23d702399df82d1e1da571c9e2388155b5409f", + "0xe105f7395ae6845ec524e9dcdf9a33600d7eece3b9dacaf7fee43deca2ad0957", + "0x5f30927f1062975cf9ad72910c99386ffc609b4dac5e1633a0a7a051ff221ca1", + "0x6f8469d0d9572b492c6c170c43f734aea5714da7d2e47ac9c10a01938439a1cb", + "0x7a75f5784c851040e436de70bafabe4abddc0baab0db46583879fdc1a7bcbd01", + "0xaa03aca82638a0c50233cb98152c122b0c21c4efb2a47035a690b5a65a2e54ed", + "0x7a75f5784c851040e436de70bafabe4abddc0baab0db46583879fdc1a7bcbd01", + "0xf6d984c0ed4b562ad15789d556f759f72b44627fb197780e88d36f450c60cd4e", + "0x52ae36bcfdb7559ffaf993e3d23b7727ada4f1403f12a9f16ba0324610852a8e", + "0x7d48f61dc2656ca3fc52efbb16bf80b4806cf43aaef56397c9d526d739ab2ad4", + "0x867a92986423254fd9f362a682695d3eb986bd5106cadabbd72dd0faf70c0cf9" + ] + } + ], + "embedding_vectors": { + "address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "basic_data_key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf00", + "code_hash_key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf01", + "slots": [ + { + "slot": 0, + "key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf40" + }, + { + "slot": 5, + "key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf45" + }, + { + "slot": 63, + "key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf7f" + }, + { + "slot": 64, + "key": "0xffd9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf181495ef75f8d2005a4a5bd3c5139cf6b5cdd1a02574832253cc9932687b332a40" + }, + { + "slot": 255, + "key": "0xffd9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf181495ef75f8d2005a4a5bd3c5139cf6b5cdd1a02574832253cc9932687b332aff" + }, + { + "slot": 256, + "key": "0xffd9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbfe717382bfeaa9f4c014431fb7bbc3c6946dd510a82d49c925d9df7735c26b16f00" + }, + { + "slot": 1000, + "key": "0xffd9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf2b1a6c18962d993b9da5ed300ce5696bdcad6e09f08b9c0735fa749d417650f9e8" + }, + { + "slot": 57896044618658097711785492504343953926634992332820282019728792003956564819968, + "key": "0xffd9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf23d37150867b80a99fea951c62d43f974e7d2f53089c5f5683b4fc1d4387286c00" + } + ], + "chunks": [ + { + "chunk": 0, + "key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf80" + }, + { + "chunk": 5, + "key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbf85" + }, + { + "chunk": 127, + "key": "0x00d9ae2d236f8713a5bf808cda488167a56cc97e4b83006f42b1c06c0c3f053bbfff" + }, + { + "chunk": 128, + "key": "0x01465f911ccfe7707c51a877fc621cffa8e700a5828cf92df27b142de919c0f6c800" + }, + { + "chunk": 300, + "key": "0x01465f911ccfe7707c51a877fc621cffa8e700a5828cf92df27b142de919c0f6c8ac" + }, + { + "chunk": 383, + "key": "0x01465f911ccfe7707c51a877fc621cffa8e700a5828cf92df27b142de919c0f6c8ff" + }, + { + "chunk": 384, + "key": "0x0171ad9e407a8d200ab2858bcf828d9016a0b4bc54e6e39dd3e31847b6220a63ce00" + } + ] + }, + "basic_data_vectors": [ + { + "code_size": 0, + "nonce": 0, + "balance": "0", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "code_size": 0, + "nonce": 1, + "balance": "1000000000000000000", + "value": "0x0000000000000000000000000000000100000000000000000de0b6b3a7640000" + }, + { + "code_size": 287454020, + "nonce": 6153737369425722316, + "balance": "1512366075204170929049582354406559215", + "value": "0x00000000112233445566778899aabbcc0123456789abcdef0123456789abcdef" + }, + { + "code_size": 24576, + "nonce": 1, + "balance": "1", + "value": "0x0000000000006000000000000000000100000000000000000000000000000001" + } + ], + "chunkify_vectors": [ + { + "name": "empty", + "code": "0x", + "chunks": [] + }, + { + "name": "short", + "code": "0x6001", + "chunks": [ + "0x0060010000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "name": "push_boundary", + "code": "0x6060606060606060606060606060606060606060606060606060606060606060606060606060606060606060606060606060606060606060606060606060", + "chunks": [ + "0x0060606060606060606060606060606060606060606060606060606060606060", + "0x0160606060606060606060606060606060606060606060606060606060606060" + ] + }, + { + "name": "push32_tail", + "code": "0x7feeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "chunks": [ + "0x007feeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "0x02eeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000" + ] + }, + { + "name": "zeros62", + "code": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "chunks": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + } + ] +} diff --git a/execution/protocol/mdgas/intrinsic_gas.go b/execution/protocol/mdgas/intrinsic_gas.go index 8ffa6a6baf5..53051d765da 100644 --- a/execution/protocol/mdgas/intrinsic_gas.go +++ b/execution/protocol/mdgas/intrinsic_gas.go @@ -41,6 +41,7 @@ type IntrinsicGasCalcArgs struct { IsEIP7981 bool IsEIP2780 bool IsAATxn bool + IsEIP8038Revised bool } type IntrinsicGasCalcResult struct { @@ -78,7 +79,11 @@ func CalcIntrinsicGas(args IntrinsicGasCalcArgs) (IntrinsicGasCalcResult, bool) case args.IsEIP2780: result.ExecutionGas = params.TxBaseEIP2780 if args.IsContractCreation { - result.ExecutionGas += params.CreateAccessEIP2780 + createAccess := params.CreateAccessEIP2780 + if args.IsEIP8038Revised { + createAccess = params.CreateAccessEIP8038Revised + } + result.ExecutionGas += createAccess if args.HasValue { result.ExecutionGas += params.TransferLogCostEIP2780 } @@ -145,6 +150,9 @@ func CalcIntrinsicGas(args IntrinsicGasCalcArgs) (IntrinsicGasCalcResult, bool) if args.IsEIP2780 { addressGas = params.TxAccessListAddressGasEIP8038 storageKeyGas = params.TxAccessListStorageKeyGasEIP8038 + if args.IsEIP8038Revised { + storageKeyGas = params.TxAccessListStorageKeyGasEIP8038Revised + } } else { addressGas = params.TxAccessListAddressGas storageKeyGas = params.TxAccessListStorageKeyGas @@ -268,9 +276,12 @@ func CalcIntrinsicGas(args IntrinsicGasCalcArgs) (IntrinsicGasCalcResult, bool) // Add the cost of authorizations var perAuthCost uint64 if args.IsEIP2780 { - if args.IsAATxn { + switch { + case args.IsAATxn && args.IsEIP8038Revised: + perAuthCost = params.PerAuthExecutionCostEIP8038Revised + case args.IsAATxn: perAuthCost = params.PerAuthExecutionCostEIP8038 - } else { + default: perAuthCost = params.ExecutionPerAuthBaseCostEIP8038 } } else { diff --git a/execution/protocol/mdgas/intrinsic_gas_test.go b/execution/protocol/mdgas/intrinsic_gas_test.go index c0e39f28de4..7dffbc1df4f 100644 --- a/execution/protocol/mdgas/intrinsic_gas_test.go +++ b/execution/protocol/mdgas/intrinsic_gas_test.go @@ -391,6 +391,44 @@ func TestEIP2780IntrinsicGas(t *testing.T) { } } +// A contract-creating transaction and the CREATE opcode must price the new +// account identically. They read separate constants, so a schedule that moves +// one without the other diverges silently: the opcode charges the revised cost +// while the transaction keeps the base one. +func TestEIP8038RevisedCreateAccess(t *testing.T) { + cases := map[string]struct { + revised bool + createAcces uint64 + }{ + "base schedule": {createAcces: params.CreateAccessEIP8038}, + "revised schedule": {revised: true, createAcces: params.CreateAccessEIP8038Revised}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + args := IntrinsicGasCalcArgs{ + IsContractCreation: true, + IsEIP2: true, + IsEIP2028: true, + IsEIP3860: true, + IsEIP7623: true, + IsEIP7976: true, + IsEIP7981: true, + IsEIP2780: true, + IsEIP8038Revised: c.revised, + } + result, overflow := CalcIntrinsicGas(args) + assert.False(t, overflow) + assert.Equal(t, params.TxBaseEIP2780+c.createAcces, result.ExecutionGas) + + // The flag prices creation only; an ordinary recipient is untouched. + args.IsContractCreation = false + eoa, overflow := CalcIntrinsicGas(args) + assert.False(t, overflow) + assert.Equal(t, params.TxBaseEIP2780+params.ColdAccountAccessEIP2780, eoa.ExecutionGas) + }) + } +} + func TestEIP2780ContractCreationStateGasIsRuntime(t *testing.T) { result, overflow := CalcIntrinsicGas(IntrinsicGasCalcArgs{ IsContractCreation: true, diff --git a/execution/protocol/params/protocol.go b/execution/protocol/params/protocol.go index 20d6ddcafc0..94da5a359ee 100644 --- a/execution/protocol/params/protocol.go +++ b/execution/protocol/params/protocol.go @@ -248,16 +248,29 @@ const ( ExtCodeWarmAccessGasEIP8038 = 2 * WarmStorageReadCostEIP2929 // EXTCODESIZE/EXTCODECOPY: account access + second read for the code // EXECUTION_PER_AUTH_BASE_COST = 101 auth-tuple bytes * 16 + ECRECOVER + COLD_ACCOUNT_ACCESS + 2*WARM_ACCESS = 7816 ExecutionPerAuthBaseCostEIP8038 = 101*TxDataNonZeroGasEIP2028 + EcrecoverGas + ColdAccountAccessCostEIP8038 + 2*WarmStorageReadCostEIP2929 - // PER_AUTH execution intrinsic = ACCOUNT_WRITE + EXECUTION_PER_AUTH_BASE_COST = 15816 + // PER_AUTH execution intrinsic = ACCOUNT_WRITE + EXECUTION_PER_AUTH_BASE_COST = 14816 PerAuthExecutionCostEIP8038 = AccountWriteCostEIP8038 + ExecutionPerAuthBaseCostEIP8038 + // Revised EIP-8038 schedule, selected by Rules.EIP8038Revised. The constants + // above stay on the values the pinned spec-test corpora were generated against, + // so only a chain config that opts in charges the revised ones. COLD_ACCOUNT_ACCESS, + // STORAGE_WRITE, ACCESS_LIST_ADDRESS_COST and EXTCODE warm access are unchanged + // by the revision and have no counterpart here. + ColdStorageAccessCostEIP8038Revised = uint64(2100) // COLD_STORAGE_ACCESS + AccountWriteCostEIP8038Revised = uint64(9000) // ACCOUNT_WRITE + CallValueTransferGasEIP8038Revised = AccountWriteCostEIP8038Revised + CallStipend // CALL_VALUE = 11300 + CreateAccessEIP8038Revised = AccountWriteCostEIP8038Revised + ColdAccountAccessCostEIP8038 // CREATE_ACCESS = 12000 + SstoreClearsScheduleRefundEIP8038Revised = (StorageWriteCostEIP8038 + ColdStorageAccessCostEIP8038Revised) * 4800 / 5000 // REFUND_STORAGE_CLEAR = 11616 + TxAccessListStorageKeyGasEIP8038Revised = ColdStorageAccessCostEIP8038Revised // ACCESS_LIST_STORAGE_KEY_COST + PerAuthExecutionCostEIP8038Revised = AccountWriteCostEIP8038Revised + ExecutionPerAuthBaseCostEIP8038 + // EIP-2780: Reduce intrinsic transaction gas (resource-based decomposition). // COLD_ACCOUNT_ACCESS and CREATE_ACCESS take their values from EIP-8038. - TxBaseEIP2780 uint64 = 12_000 // TX_BASE: sender ECDSA recovery plus access and write - TxValueCostEIP2780 uint64 = 4_244 // TX_VALUE_COST: recipient balance write for value transfers - TransferLogCostEIP2780 uint64 = 1_756 // TRANSFER_LOG_COST: EIP-7708 transfer log - ColdAccountAccessEIP2780 uint64 = 3_000 // COLD_ACCOUNT_ACCESS: recipient account touch - CreateAccessEIP2780 uint64 = 11_000 // CREATE_ACCESS: ACCOUNT_WRITE(8000) + COLD_STORAGE_ACCESS(3000) + TxBaseEIP2780 uint64 = 12_000 // TX_BASE: sender ECDSA recovery plus access and write + TxValueCostEIP2780 uint64 = 4_244 // TX_VALUE_COST: recipient balance write for value transfers + TransferLogCostEIP2780 uint64 = 1_756 // TRANSFER_LOG_COST: EIP-7708 transfer log + ColdAccountAccessEIP2780 uint64 = 3_000 // COLD_ACCOUNT_ACCESS: recipient account touch + CreateAccessEIP2780 uint64 = CreateAccessEIP8038 // CREATE_ACCESS shares EIP-8038's value; deriving keeps the two from drifting ) // EIP-7702: Set EOA account code diff --git a/execution/protocol/txn_executor.go b/execution/protocol/txn_executor.go index b2d485ee680..81b334e9ec2 100644 --- a/execution/protocol/txn_executor.go +++ b/execution/protocol/txn_executor.go @@ -856,7 +856,8 @@ func (st *TxnExecutor) verifyAuthorities(auths []types.Authorization, chainID *u if auths == nil { return gasRemaining, gasUsed, nil } - isAmsterdam := st.evm.ChainRules().IsAmsterdam + rules := st.evm.ChainRules() + isAmsterdam := rules.IsAmsterdam writtenAccounts := map[accounts.Address]struct{}{st.msg.From(): {}} if !st.msg.Value().IsZero() { writtenAccounts[st.msg.To()] = struct{}{} @@ -920,7 +921,11 @@ func (st *TxnExecutor) verifyAuthorities(auths []types.Authorization, chainID *u return gasRemaining, gasUsed, vm.ErrRuntimeOutOfGas } if _, written := writtenAccounts[authority]; !written { - if !mdgas.Consume(&gasRemaining, &gasUsed, params.AccountWriteCostEIP8038, mdgas.ExecutionGas) { + accountWrite := params.AccountWriteCostEIP8038 + if rules.EIP8038Revised { + accountWrite = params.AccountWriteCostEIP8038Revised + } + if !mdgas.Consume(&gasRemaining, &gasUsed, accountWrite, mdgas.ExecutionGas) { return gasRemaining, gasUsed, vm.ErrRuntimeOutOfGas } writtenAccounts[authority] = struct{}{} @@ -989,5 +994,6 @@ func (st *TxnExecutor) calcIntrinsicGas(contractCreation bool, auths []types.Aut IsEIP7976: rules.IsAmsterdam, IsEIP7981: rules.IsAmsterdam, IsEIP2780: rules.IsAmsterdam, + IsEIP8038Revised: rules.EIP8038Revised, }) } diff --git a/execution/stagedsync/committer.go b/execution/stagedsync/committer.go index 6a8587778d7..9dc9b52b123 100644 --- a/execution/stagedsync/committer.go +++ b/execution/stagedsync/committer.go @@ -559,7 +559,7 @@ func (cc *commitmentCalculator) computeBlockFromBAL(ctx context.Context, pb *pen cc.fail(ctx, target, fmt.Errorf("BAL-driven compute-ahead block %d: %w", req.blockNum, err)) return } - if !bytes.Equal(rh, req.stateRoot[:]) { + if headerRootMismatch(rh, req.stateRoot[:]) { cc.fail(ctx, target, fmt.Errorf("%w: BAL-driven block %d root %x expected %x", ErrWrongTrieRoot, req.blockNum, rh, req.stateRoot)) return @@ -664,6 +664,8 @@ func (cc *commitmentCalculator) shadowCrossCheck(ctx context.Context, target com cc.fail(ctx, target, fmt.Errorf("shadow incremental compute: %w", err)) return } + // Both operands are roots this node computed, so the header-root toggle does + // not apply: this is the only thing validating the BAL-driven path. if !bytes.Equal(rh, balRoot) { cc.fail(ctx, target, fmt.Errorf("%w: shadow mismatch block %d incremental %x BAL-driven %x", ErrWrongTrieRoot, target.blockNum, rh, balRoot)) @@ -771,7 +773,7 @@ func (cc *commitmentCalculator) compute(ctx context.Context, t commitTarget, m c if !m.checkRoot { return } - mismatch := !bytes.Equal(rh, t.stateRoot[:]) + mismatch := headerRootMismatch(rh, t.stateRoot[:]) if !m.publishRoot && !mismatch { return } diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index 684560f8c64..5fad8d5b526 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -108,6 +108,28 @@ func restoreTxNum(ctx context.Context, cfg *ExecuteBlockCfg, applyTx kv.Tx, curr return inputTxNum, maxTxNum, offsetFromBlockBeginning, blockNum, nil } +// executeInParallel picks the executor. The parallel executor's normalized write +// set produces a different bin-trie root than the serial one for the same block, +// so the bin variant stays on the serial executor until that is resolved. +func executeInParallel(variant commitment.TrieVariant, exec3Parallel, experimentalBAL bool) bool { + if variant == commitment.VariantBinPatriciaTrie { + return false + } + return exec3Parallel || experimentalBAL +} + +// deferCommitmentUpdates reports whether Process() may leave branch updates as a +// pending update flushed at the block boundary instead of applying them inline. +// Deferring cuts re-org validation overhead; the parallel apply path also needs +// Flush() to carry the pending update across sync cycles. The bin trie has no +// deferred-update path and refuses the request, so it stays on the inline path. +func deferCommitmentUpdates(variant commitment.TrieVariant, isForkValidation, parallel, isApplyingBlocks bool) bool { + if variant == commitment.VariantBinPatriciaTrie { + return false + } + return isForkValidation || (parallel && isApplyingBlocks) +} + func ExecV3(ctx context.Context, execStage *StageState, u Unwinder, cfg ExecuteBlockCfg, doms *execctx.SharedDomains, rwTx kv.TemporalRwTx, @@ -199,12 +221,7 @@ func ExecV3(ctx context.Context, doms.EnableParaTrieDB(cfg.db) doms.EnableTrieWarmup(true) doms.SetDeferCommitmentUpdates(false) - // Enable deferred commitment updates for fork validation and parallel initial sync. - // Deferred updates batch commitment calculations to block boundaries rather than - // per-transaction, significantly reducing re-org validation overhead. - // For the parallel path during initial sync, Flush() now includes pending updates, - // so they are no longer silently discarded between StageLoopIteration cycles. - if isForkValidation || (parallel && isApplyingBlocks) { + if deferCommitmentUpdates(doms.GetCommitmentCtx().Trie().Variant(), isForkValidation, parallel, isApplyingBlocks) { doms.SetDeferCommitmentUpdates(true) } defer doms.SetDeferCommitmentUpdates(false) @@ -753,6 +770,20 @@ func (te *txExecutor) executeBlocks(ctx context.Context, startBlockNum uint64, m return nil } +// headerRootMismatch reports whether a computed state root fails the header +// state-root check. Variant-independent and on by default; +// dbg.CheckHeaderStateRoot switches the check off for a chain whose headers +// this node cannot reproduce. +func headerRootMismatch(computed, expected []byte) bool { + if !dbg.CheckHeaderStateRoot { + // Every execution entry point runs through here, so this is what reaches the + // integration and test runners too, not just node startup. + dbg.WarnHeaderStateRootCheckDisabled() + return false + } + return !bytes.Equal(computed, expected) +} + func handleIncorrectRootHashError(blockNumber uint64, blockHash common.Hash, applyTx kv.TemporalRwTx, cfg ExecuteBlockCfg, s *StageState, logger log.Logger, u Unwinder) error { if cfg.badBlockHalt { return fmt.Errorf("%w, block=%d", ErrWrongTrieRoot, blockNumber) @@ -835,7 +866,7 @@ func computeAndCheckCommitmentV3(ctx context.Context, header *types.Header, appl return false, times, fmt.Errorf("compute commitment: %w", err) } - if !bytes.Equal(computedRootHash, header.Root[:]) { + if headerRootMismatch(computedRootHash, header.Root[:]) { logger.Warn(fmt.Sprintf("[%s] Wrong trie root of block %d: %x, expected (from header): %x. Block hash: %x", e.LogPrefix(), header.Number.Uint64(), computedRootHash, header.Root[:], header.Hash())) err = handleIncorrectRootHashError(header.Number.Uint64(), header.Hash(), applyTx, cfg, e, logger, u) return false, times, err diff --git a/execution/stagedsync/exec3_serial.go b/execution/stagedsync/exec3_serial.go index af7f6249d22..bc407a2bab3 100644 --- a/execution/stagedsync/exec3_serial.go +++ b/execution/stagedsync/exec3_serial.go @@ -1,7 +1,6 @@ package stagedsync import ( - "bytes" "context" "errors" "fmt" @@ -199,7 +198,7 @@ func (se *serialExecutor) exec(ctx context.Context, execStage *StageState, u Unw } se.doms.SetChangesetAccumulator(nil) - if !bytes.Equal(rh, header.Root[:]) { + if headerRootMismatch(rh, header.Root[:]) { se.logWrongTrieRoot(fmt.Sprintf("[%s] Wrong trie root of block %d: %x, expected (from header): %x. Block hash: %x", se.logPrefix, header.Number.Uint64(), rh, header.Root[:], header.Hash())) return b.HeaderNoCopy(), rwTx, fmt.Errorf("%w, block=%d", ErrWrongTrieRoot, blockNum) } diff --git a/execution/stagedsync/header_root_check_test.go b/execution/stagedsync/header_root_check_test.go new file mode 100644 index 00000000000..7b84f4d202b --- /dev/null +++ b/execution/stagedsync/header_root_check_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package stagedsync + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/db/state/statecfg" +) + +// The header state-root check must be on by default and skippable only through +// dbg.CheckHeaderStateRoot, independently of the commitment trie variant. +func TestHeaderRootCheckDefaultOnAndTogglable(t *testing.T) { + computed := make([]byte, 32) + expected := make([]byte, 32) + expected[0] = 0x01 + + require.True(t, dbg.CheckHeaderStateRoot, "header root check must default to enabled") + + origBin := statecfg.ExperimentalBinCommitment + origCheck := dbg.CheckHeaderStateRoot + t.Cleanup(func() { + statecfg.ExperimentalBinCommitment = origBin + dbg.CheckHeaderStateRoot = origCheck + }) + + for _, bin := range []bool{false, true} { + statecfg.ExperimentalBinCommitment = bin + + dbg.CheckHeaderStateRoot = true + require.True(t, headerRootMismatch(computed, expected)) + require.False(t, headerRootMismatch(computed, computed)) + + dbg.CheckHeaderStateRoot = false + require.False(t, headerRootMismatch(computed, expected)) + } +} diff --git a/execution/stagedsync/pbin_defer_test.go b/execution/stagedsync/pbin_defer_test.go new file mode 100644 index 00000000000..5377a2366c4 --- /dev/null +++ b/execution/stagedsync/pbin_defer_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package stagedsync + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/execution/commitment" +) + +// The commitment context panics on a deferral request under the bin variant, so +// ExecV3 must never make one. +func TestPBinDeferCommitmentUpdatesExcludesBin(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + variant commitment.TrieVariant + isForkValidation bool + parallel bool + isApplyingBlocks bool + want bool + }{ + {name: "hex fork validation", variant: commitment.VariantHexPatriciaTrie, isForkValidation: true, want: true}, + {name: "hex parallel apply", variant: commitment.VariantHexPatriciaTrie, parallel: true, isApplyingBlocks: true, want: true}, + {name: "hex parallel not applying", variant: commitment.VariantHexPatriciaTrie, parallel: true}, + {name: "hex serial apply", variant: commitment.VariantHexPatriciaTrie, isApplyingBlocks: true}, + {name: "parallel trie fork validation", variant: commitment.VariantParallelHexPatricia, isForkValidation: true, want: true}, + {name: "bin fork validation", variant: commitment.VariantBinPatriciaTrie, isForkValidation: true}, + {name: "bin parallel apply", variant: commitment.VariantBinPatriciaTrie, parallel: true, isApplyingBlocks: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := deferCommitmentUpdates(tc.variant, tc.isForkValidation, tc.parallel, tc.isApplyingBlocks) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/execution/stagedsync/pbin_parallel_exec_test.go b/execution/stagedsync/pbin_parallel_exec_test.go new file mode 100644 index 00000000000..ec4eba53201 --- /dev/null +++ b/execution/stagedsync/pbin_parallel_exec_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package stagedsync + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/execution/commitment" +) + +// The parallel executor's normalized write set roots differently under the bin trie +// than the serial path, so bin must run serially whatever the parallel toggles say. +func TestPBinExecuteInParallelExcludesBin(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + variant commitment.TrieVariant + exec3Parallel bool + experimentalBAL bool + want bool + }{ + {name: "hex parallel", variant: commitment.VariantHexPatriciaTrie, exec3Parallel: true, want: true}, + {name: "hex bal", variant: commitment.VariantHexPatriciaTrie, experimentalBAL: true, want: true}, + {name: "hex serial", variant: commitment.VariantHexPatriciaTrie}, + {name: "parallel trie parallel", variant: commitment.VariantParallelHexPatricia, exec3Parallel: true, want: true}, + {name: "bin parallel", variant: commitment.VariantBinPatriciaTrie, exec3Parallel: true}, + {name: "bin bal", variant: commitment.VariantBinPatriciaTrie, experimentalBAL: true}, + {name: "bin serial", variant: commitment.VariantBinPatriciaTrie}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := executeInParallel(tc.variant, tc.exec3Parallel, tc.experimentalBAL) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/execution/stagedsync/stage_execute.go b/execution/stagedsync/stage_execute.go index 4fa4990f57f..a17eb96d74c 100644 --- a/execution/stagedsync/stage_execute.go +++ b/execution/stagedsync/stage_execute.go @@ -380,7 +380,8 @@ func SpawnExecuteBlocksStage(s *StageState, u Unwinder, doms *execctx.SharedDoma return nil } - if err := ExecV3(ctx, s, u, cfg, doms, rwTx, dbg.Exec3Parallel || cfg.experimentalBAL, to, logger); err != nil { + parallel := executeInParallel(doms.GetCommitmentCtx().Trie().Variant(), dbg.Exec3Parallel, cfg.experimentalBAL) + if err := ExecV3(ctx, s, u, cfg, doms, rwTx, parallel, to, logger); err != nil { return err } return nil diff --git a/execution/state/genesiswrite/genesis_write.go b/execution/state/genesiswrite/genesis_write.go index 4a0823e7118..71b2d935bdb 100644 --- a/execution/state/genesiswrite/genesis_write.go +++ b/execution/state/genesiswrite/genesis_write.go @@ -379,8 +379,9 @@ func GenesisToBlock(tb testing.TB, g *types.Genesis, dirs datadir.Dirs, logger l defer tx.Rollback() // Genesis is a one-shot commitment over an empty DB; the parallel trie has no - // context factory wired here, so use the sequential trie (identical root). - sd, err := execctx.NewSharedDomains(ctx, tx, logger, execctx.WithSequentialCommitment()) + // context factory wired here, so demote it to the sequential trie (identical + // root). The bin variant is kept — block 0 must be the root the executor computes. + sd, err := execctx.NewSharedDomains(ctx, tx, logger, execctx.WithoutParallelCommitment()) if err != nil { return nil, nil, err } diff --git a/execution/state/genesiswrite/pbin_genesis_test.go b/execution/state/genesiswrite/pbin_genesis_test.go new file mode 100644 index 00000000000..9a2a8b3f105 --- /dev/null +++ b/execution/state/genesiswrite/pbin_genesis_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package genesiswrite_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/state/genesiswrite" + "github.com/erigontech/erigon/execution/types" +) + +func withBinCommitment(t *testing.T, on bool) { + t.Helper() + orig := statecfg.ExperimentalBinCommitment + origParallel, origStreaming := statecfg.ExperimentalParallelCommitment, statecfg.ExperimentalStreamingCommitment + t.Cleanup(func() { + statecfg.ExperimentalBinCommitment = orig + statecfg.ExperimentalParallelCommitment = origParallel + statecfg.ExperimentalStreamingCommitment = origStreaming + }) + statecfg.ExperimentalBinCommitment = on + if on { + // erigondb.toml resolution refuses bin combined with either: the bin trie + // is sequential-only, regardless of a process-wide parallel/streaming default. + statecfg.ExperimentalParallelCommitment = false + statecfg.ExperimentalStreamingCommitment = false + } +} + +func pbinTestGenesis() *types.Genesis { + return &types.Genesis{ + Config: chain.AllProtocolChanges, + Alloc: types.GenesisAlloc{ + common.HexToAddress("0x0000000000000000000000000000000000000042"): {Balance: big.NewInt(1)}, + common.HexToAddress("0x00000000000000000000000000000000000000ff"): {Balance: big.NewInt(0xdeadbeef), Nonce: 3}, + }, + } +} + +// Genesis produces the block-0 root the executor is later checked against, so it must +// use the variant the datadir uses, not always the hex trie. +func TestPBinGenesisComputesBinaryRoot(t *testing.T) { + // No t.Parallel: mutates process-global statecfg flags. + logger := log.New() + g := pbinTestGenesis() + + withBinCommitment(t, false) + hexBlock, _, err := genesiswrite.GenesisToBlock(t, g, datadir.New(t.TempDir()), logger) + require.NoError(t, err) + + withBinCommitment(t, true) + binBlock, _, err := genesiswrite.GenesisToBlock(t, g, datadir.New(t.TempDir()), logger) + require.NoError(t, err) + + require.NotEqual(t, hexBlock.Root(), binBlock.Root(), "genesis under the bin variant returned the hex root") + require.Equal(t, common.BytesToHash(pbinGenesisRoot(t, g)), binBlock.Root()) +} + +// Oracle for GenesisToBlock: the same root computed through SharedDomains on bin. +func pbinGenesisRoot(t *testing.T, g *types.Genesis) []byte { + t.Helper() + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + tx, err := db.BeginTemporalRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(t.Context(), tx, log.New()) + require.NoError(t, err) + defer sd.Close() + require.Equal(t, commitment.VariantBinPatriciaTrie, sd.GetCommitmentCtx().Trie().Variant()) + + head, _ := genesiswrite.GenesisWithoutStateToBlock(g) + root, _, err := genesiswrite.ComputeGenesisCommitment(t.Context(), g, tx, sd, head) + require.NoError(t, err) + return root +} diff --git a/execution/tests/testforks/forks.go b/execution/tests/testforks/forks.go index 8cb4780a167..b266b10befb 100644 --- a/execution/tests/testforks/forks.go +++ b/execution/tests/testforks/forks.go @@ -57,6 +57,11 @@ var blobSchedule = map[string]*params.BlobConfig{ } // Forks table defines supported forks and their chain config. +// BinaryTree names the experimental EIP-8297 fork. Selecting it switches the +// commitment engine process-wide, so a run covering it must not also cover a +// Merkle-Patricia fork. +const BinaryTree = "BinaryTree" + var Forks = map[string]*chain.Config{} func init() { @@ -219,6 +224,14 @@ func init() { cAms.AmsterdamTime = common.NewUint64(0) Forks["Amsterdam"] = cAms + // BinaryTree is Amsterdam with state committed through EIP-8297's binary tree + // instead of the MPT, and the runner selects it from the network name. Its + // fixtures are generated from head-of-spec rather than a pinned release, so it + // also charges EIP-8038's revised state-access schedule; Amsterdam stays on the + // pre-revision one its pinned corpora were generated against. + Forks[BinaryTree] = configCopy(cAms) + Forks[BinaryTree].EIP8038Revised = true + // BPO3/BPO4 continue from BPO2 as a separate chain c = configCopy(c) c.Bpo3Time = common.NewUint64(15_000) diff --git a/execution/tests/testutil/block_test_util.go b/execution/tests/testutil/block_test_util.go index 32b1ddfdea4..c696f5328d7 100644 --- a/execution/tests/testutil/block_test_util.go +++ b/execution/tests/testutil/block_test_util.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "math/big" + "sync" "testing" "github.com/holiman/uint256" @@ -38,7 +39,9 @@ import ( "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/execmodule" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/rlp" @@ -219,6 +222,89 @@ func (bt *BlockTest) Run(t *testing.T) error { return err } +// The commitment variant and its hash are datadir properties resolved +// process-wide, not per-tester options, so one run covers BinaryTree fixtures +// or Merkle-Patricia ones and never both. The latch is what keeps the block +// runner's concurrent workers off a racing write to the globals, and what turns +// a mixed corpus into an error instead of fixtures silently re-rooted under the +// wrong engine. +var commitmentVariant struct { + sync.Mutex + holders int + bin bool + prevBin bool + prevHash string + prevSuite string +} + +func commitmentVariantName(bin bool) string { + if bin { + return "binary" + } + return "Merkle-Patricia" +} + +// selectCommitmentVariant commits the process to one commitment trie. Under go +// test the choice is handed back once the last holder is done, so a later test +// reads the process it expects; the CLI passes a nil tb and keeps it for the run. +func selectCommitmentVariant(tb testing.TB, bin bool) error { + release, err := acquireCommitmentVariant(bin) + if err != nil { + return err + } + if tb != nil { + tb.Cleanup(release) + } + return nil +} + +// acquireCommitmentVariant latches the variant and returns the release its +// caller owes. Fixture files run as parallel subtests, so holders overlap: the +// first applies the selection and only the last hands it back. +func acquireCommitmentVariant(bin bool) (func(), error) { + commitmentVariant.Lock() + defer commitmentVariant.Unlock() + + if commitmentVariant.holders > 0 { + if commitmentVariant.bin != bin { + return nil, fmt.Errorf("the commitment trie is selected process-wide: this run started under the %s trie and cannot also cover %s fixtures", + commitmentVariantName(commitmentVariant.bin), commitmentVariantName(bin)) + } + commitmentVariant.holders++ + return releaseCommitmentVariant, nil + } + + prevBin, prevHash, prevSuite := statecfg.ExperimentalBinCommitment, statecfg.BinCommitmentHash, commitment.PBinHashSuiteName() + if bin { + if err := commitment.SetPBinHashSuite(commitment.PBinHashBlake3); err != nil { + return nil, err + } + // Setting the statecfg field is what makes the settings resolver persist + // blake3 and re-apply it; calling SetPBinHashSuite alone would be undone by + // the resolver's keccak default. + statecfg.ExperimentalBinCommitment = true + statecfg.BinCommitmentHash = commitment.PBinHashBlake3 + } + commitmentVariant.bin = bin + commitmentVariant.prevBin, commitmentVariant.prevHash, commitmentVariant.prevSuite = prevBin, prevHash, prevSuite + commitmentVariant.holders = 1 + return releaseCommitmentVariant, nil +} + +func releaseCommitmentVariant() { + commitmentVariant.Lock() + defer commitmentVariant.Unlock() + + commitmentVariant.holders-- + if commitmentVariant.holders > 0 { + return + } + statecfg.ExperimentalBinCommitment, statecfg.BinCommitmentHash = commitmentVariant.prevBin, commitmentVariant.prevHash + if err := commitment.SetPBinHashSuite(commitmentVariant.prevSuite); err != nil { + panic(err) + } +} + // newTester builds the ExecModuleTester for this block test. tb may be nil for // CLI usage, in which case the caller owns the tester's lifecycle and MUST Close it. func (bt *BlockTest) newTester(tb testing.TB) (*execmoduletester.ExecModuleTester, error) { @@ -226,6 +312,9 @@ func (bt *BlockTest) newTester(tb testing.TB) (*execmoduletester.ExecModuleTeste if !ok { return nil, testforks.UnsupportedForkError{Name: bt.json.Network} } + if err := selectCommitmentVariant(tb, bt.json.Network == testforks.BinaryTree); err != nil { + return nil, err + } engine := rulesconfig.CreateRulesEngineBareBones(context.Background(), config, log.New()) mOpts := []execmoduletester.Option{ execmoduletester.WithGenesisSpec(bt.genesis(config)), diff --git a/execution/tests/testutil/block_test_util_test.go b/execution/tests/testutil/block_test_util_test.go new file mode 100644 index 00000000000..f0963a776b0 --- /dev/null +++ b/execution/tests/testutil/block_test_util_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package testutil + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" +) + +// TestSelectCommitmentVariantLatches: the trie the fixtures run under is a +// process-global choice, so a mixed run has to fail rather than re-root one +// network's fixtures under the other's engine. No t.Parallel here — the test +// writes the same globals a concurrent one would read. +func TestSelectCommitmentVariantLatches(t *testing.T) { + t.Run("bin refuses to share the process", func(t *testing.T) { + require.NoError(t, selectCommitmentVariant(t, true)) + require.True(t, statecfg.ExperimentalBinCommitment) + require.Equal(t, commitment.PBinHashBlake3, commitment.PBinHashSuiteName()) + + require.NoError(t, selectCommitmentVariant(t, true), "the same variant twice is the ordinary case") + require.Error(t, selectCommitmentVariant(t, false)) + }) + + require.False(t, statecfg.ExperimentalBinCommitment, "the subtest has to hand the process back") + require.Equal(t, commitment.PBinHashKeccak, commitment.PBinHashSuiteName()) + + t.Run("hex refuses to share the process", func(t *testing.T) { + require.NoError(t, selectCommitmentVariant(t, false)) + require.False(t, statecfg.ExperimentalBinCommitment) + require.Error(t, selectCommitmentVariant(t, true)) + }) +} + +// TestSelectCommitmentVariantHoldsForOverlappingUsers: fixture files run as +// parallel subtests, so two of them hold the same variant at once and the one +// that finishes first must not hand the process back under the other. +func TestSelectCommitmentVariantHoldsForOverlappingUsers(t *testing.T) { + first, err := acquireCommitmentVariant(true) + require.NoError(t, err) + second, err := acquireCommitmentVariant(true) + require.NoError(t, err) + + first() + require.True(t, statecfg.ExperimentalBinCommitment, "a variant is still held, so it cannot be handed back") + require.Equal(t, commitment.PBinHashBlake3, commitment.PBinHashSuiteName()) + + second() + require.False(t, statecfg.ExperimentalBinCommitment, "the last holder has to hand the process back") + require.Equal(t, commitment.PBinHashKeccak, commitment.PBinHashSuiteName()) +} diff --git a/execution/vm/eips.go b/execution/vm/eips.go index 4d61cbc99f0..f14f0f3c634 100644 --- a/execution/vm/eips.go +++ b/execution/vm/eips.go @@ -391,3 +391,9 @@ func enable8038(jt *JumpTable) { jt[CREATE].constantGas = params.CreateAccessEIP8038 jt[CREATE2].constantGas = params.CreateAccessEIP8038 } + +// enable8038Revised repoints the opcodes whose EIP-8038 cost the revision moved. +func enable8038Revised(jt *JumpTable) { + jt[CREATE].constantGas = params.CreateAccessEIP8038Revised + jt[CREATE2].constantGas = params.CreateAccessEIP8038Revised +} diff --git a/execution/vm/evmtypes/rules.go b/execution/vm/evmtypes/rules.go index adb095c30f6..94f38a57249 100644 --- a/execution/vm/evmtypes/rules.go +++ b/execution/vm/evmtypes/rules.go @@ -48,6 +48,7 @@ func (bc *BlockContext) Rules(c *chain.Config) *chain.Rules { IsPrague: c.IsPrague(bc.Time) || c.IsBhilai(bc.BlockNumber), IsOsaka: c.IsOsaka(bc.Time), IsAmsterdam: c.IsAmsterdam(bc.Time), + EIP8038Revised: c.EIP8038Revised, DisabledEIPs: c.DisabledEIPs, IsAura: c.Aura != nil, } diff --git a/execution/vm/gas_table.go b/execution/vm/gas_table.go index f43d0b0e681..dae4afc3d02 100644 --- a/execution/vm/gas_table.go +++ b/execution/vm/gas_table.go @@ -34,6 +34,9 @@ import ( func callValueTransferGas(rules *chain.Rules) uint64 { if rules.IsAmsterdam { + if rules.EIP8038Revised { + return params.CallValueTransferGasEIP8038Revised + } return params.CallValueTransferGasEIP8038 } return params.CallValueTransferGas @@ -46,8 +49,27 @@ func coldAccountAccessCost(rules *chain.Rules) uint64 { return params.ColdAccountAccessCostEIP2929 } +// accountWriteCost and sstoreClearsRefund are only reached under Amsterdam rules; +// the pre-Amsterdam values have no EIP-8038 counterpart to fall back to. +func accountWriteCost(rules *chain.Rules) uint64 { + if rules.EIP8038Revised { + return params.AccountWriteCostEIP8038Revised + } + return params.AccountWriteCostEIP8038 +} + +func sstoreClearsRefund(rules *chain.Rules) uint64 { + if rules.EIP8038Revised { + return params.SstoreClearsScheduleRefundEIP8038Revised + } + return params.SstoreClearsScheduleRefundEIP8038 +} + func coldStorageAccessCost(rules *chain.Rules) uint64 { if rules.IsAmsterdam { + if rules.EIP8038Revised { + return params.ColdStorageAccessCostEIP8038Revised + } return params.ColdStorageAccessCostEIP8038 } return params.ColdSloadCostEIP2929 diff --git a/execution/vm/gas_table_test.go b/execution/vm/gas_table_test.go index f363ed17456..9631862cf9e 100644 --- a/execution/vm/gas_table_test.go +++ b/execution/vm/gas_table_test.go @@ -42,6 +42,7 @@ import ( "github.com/erigontech/erigon/execution/protocol/mdgas" "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/state" + "github.com/erigontech/erigon/execution/tests/testforks" "github.com/erigontech/erigon/execution/tests/testutil" "github.com/erigontech/erigon/execution/tracing" "github.com/erigontech/erigon/execution/types" @@ -254,6 +255,44 @@ func TestEIP8038SStore(t *testing.T) { } } +// TestEIP8038RevisedScheduleSelectedByConfig pins the revised state-access schedule +// as a chain-config property rather than a global: the same SSTORE pays the +// pre-revision cold access under a plain Amsterdam config and the revised one when +// the config selects it. +func TestEIP8038RevisedScheduleSelectedByConfig(t *testing.T) { + sstoreExecutionGas := func(t *testing.T, config *chain.Config) uint64 { + t.Helper() + tx, sd := testTemporalTxSD(t) + txNum, _, err := sd.SeekCommitment(t.Context(), tx) + require.NoError(t, err) + r, w := state.NewReaderV3(sd.AsGetter(tx)), state.NewWriter(sd.AsPutDel(tx), nil, txNum) + s := state.New(r) + defer s.Close() + address := accounts.InternAddress(common.BytesToAddress([]byte("contract"))) + require.NoError(t, s.CreateAccount(address, true)) + require.NoError(t, s.SetCode(address, hexutil.MustDecode("0x6001600055"), tracing.CodeChangeUnspecified)) + vmctx := evmtypes.BlockContext{ + CanTransfer: func(evmtypes.IntraBlockState, accounts.Address, uint256.Int) (bool, error) { return true, nil }, + Transfer: func(evmtypes.IntraBlockState, accounts.Address, accounts.Address, uint256.Int, bool, *chain.Rules) error { + return nil + }, + } + _ = s.CommitBlock(vmctx.Rules(config), w) + vmenv := vm.NewEVM(vmctx, evmtypes.TxContext{}, s, config, vm.Config{}) + pool := mdgas.MdGas{Execution: 10_000_000, State: 10_000_000} + _, gas, _, err := vmenv.Call(accounts.ZeroAddress, address, nil, pool, uint256.Int{}, false /* bailout */) + require.NoError(t, err) + return pool.Execution - gas.Execution + } + + pinned := sstoreExecutionGas(t, testforks.Forks["Amsterdam"]) + revised := sstoreExecutionGas(t, testforks.Forks[testforks.BinaryTree]) + require.Equal(t, + params.ColdStorageAccessCostEIP8038-params.ColdStorageAccessCostEIP8038Revised, + pinned-revised, + "revised schedule must lower the cold storage access by the repricing delta") +} + func TestEIP7928SStoreReadRequiresAffordableAccess(t *testing.T) { tests := []struct { name string diff --git a/execution/vm/interpreter.go b/execution/vm/interpreter.go index c9218b082fd..6c339a766d0 100644 --- a/execution/vm/interpreter.go +++ b/execution/vm/interpreter.go @@ -301,6 +301,8 @@ func copyJumpTable(jt *JumpTable) *JumpTable { func jumpTable(chainRules *chain.Rules, cfg Config) *JumpTable { var jt *JumpTable switch { + case chainRules.IsAmsterdam && chainRules.EIP8038Revised: + jt = &amsterdamEIP8038RevisedSet case chainRules.IsAmsterdam: jt = &amsterdamInstructionSet case chainRules.IsOsaka: diff --git a/execution/vm/jump_table.go b/execution/vm/jump_table.go index 88e80db7e4f..f384a23203b 100644 --- a/execution/vm/jump_table.go +++ b/execution/vm/jump_table.go @@ -71,6 +71,7 @@ var ( pragueInstructionSet = newPragueInstructionSet() osakaInstructionSet = newOsakaInstructionSet() amsterdamInstructionSet = newAmsterdamInstructionSet() + amsterdamEIP8038RevisedSet = newAmsterdamEIP8038RevisedInstructionSet() ) // JumpTable contains the EVM opcodes supported at a given fork. @@ -109,6 +110,13 @@ func newAmsterdamInstructionSet() JumpTable { return instructionSet } +func newAmsterdamEIP8038RevisedInstructionSet() JumpTable { + instructionSet := newAmsterdamInstructionSet() + enable8038Revised(&instructionSet) + validateAndFillMaxStack(&instructionSet) + return instructionSet +} + func newOsakaInstructionSet() JumpTable { instructionSet := newPragueInstructionSet() enable7939(&instructionSet) // EIP-7939 (CLZ opcode) diff --git a/execution/vm/operations_acl.go b/execution/vm/operations_acl.go index 25f979b3786..9113f0959da 100644 --- a/execution/vm/operations_acl.go +++ b/execution/vm/operations_acl.go @@ -45,10 +45,10 @@ func makeGasSStoreFunc(clearingRefund uint64) gasFunc { } var coldAccess, writeCreate, writeExisting, clearRefund, stateCreate uint64 if rules.IsAmsterdam { - coldAccess = params.ColdStorageAccessCostEIP8038 + coldAccess = coldStorageAccessCost(rules) writeCreate = params.StorageWriteCostEIP8038 writeExisting = params.StorageWriteCostEIP8038 - clearRefund = params.SstoreClearsScheduleRefundEIP8038 + clearRefund = sstoreClearsRefund(rules) stateCreate = params.StateGasPerStorageSet } else { coldAccess = params.SstoreColdAccessEIP2929 @@ -268,7 +268,7 @@ func makeSelfdestructGasFn(refundsEnabled bool) gasFunc { evm.IntraBlockState().MarkAddressAccess(address, false) if empty && !balance.IsZero() { if evm.chainRules.IsAmsterdam { - gas.Execution += params.AccountWriteCostEIP8038 + gas.Execution += accountWriteCost(evm.chainRules) gas.State = params.StateGasNewAccount } else { gas.Execution += params.CreateBySelfdestructGas diff --git a/go.mod b/go.mod index 7ef100c82cc..d7071bfbbca 100644 --- a/go.mod +++ b/go.mod @@ -121,6 +121,7 @@ require ( google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + lukechampine.com/blake3 v1.4.1 sigs.k8s.io/yaml v1.6.0 ) @@ -440,7 +441,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect honnef.co/go/tools v0.7.0 // indirect - lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.66.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/node/cli/default_flags.go b/node/cli/default_flags.go index 72ecd6f758a..e1b953e22aa 100644 --- a/node/cli/default_flags.go +++ b/node/cli/default_flags.go @@ -280,6 +280,8 @@ var DefaultFlags = []cli.Flag{ &utils.ExperimentalParallelCommitmentFlag, &utils.ExperimentalStreamingCommitmentFlag, + &utils.ExperimentalBinCommitmentFlag, + &utils.ExperimentalBinCommitmentHashFlag, &utils.MCPDisableFlag, &utils.MCPAddrFlag, diff --git a/node/eth/backend.go b/node/eth/backend.go index f7ce65085bc..7d487de040f 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -75,6 +75,7 @@ import ( "github.com/erigontech/erigon/execution/builder" "github.com/erigontech/erigon/execution/chain" chainspec "github.com/erigontech/erigon/execution/chain/spec" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/engineapi" "github.com/erigontech/erigon/execution/engineapi/engine_block_downloader" "github.com/erigontech/erigon/execution/exec" @@ -312,6 +313,15 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger if config.ExperimentalStreamingCommitment { statecfg.ExperimentalStreamingCommitment = true } + if config.ExperimentalBinCommitment { + statecfg.ExperimentalBinCommitment = true + } + if config.BinCommitmentHash != "" { + if err := commitment.SetPBinHashSuite(config.BinCommitmentHash); err != nil { + return err + } + statecfg.BinCommitmentHash = config.BinCommitmentHash + } if err := stages.UpdateMetrics(tx); err != nil { return err @@ -327,6 +337,8 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger return nil, err } + dbg.WarnHeaderStateRootCheckDisabled() + ctx, ctxCancel := context.WithCancel(context.Background()) // kv_remote architecture does blocks on stream.Send - means current architecture require unlimited amount of txs to provide good throughput @@ -357,6 +369,17 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger return nil, err } + // After the resolve: a flagless restart of a bin datadir adopts the variant + // and the hash recorded there, so both are read back rather than assumed. + if statecfg.ExperimentalBinCommitment { + peers := "matches the execution-specs reference" + if commitment.PBinHashSuiteName() == commitment.PBinHashKeccak { + peers = "agrees with no other client" + } + logger.Warn("EXPERIMENTAL BINARY COMMITMENT TRIE IS ENABLED: roots follow EIP-8297 and "+peers+"; eth_getProof, eth_getWitness, eth_simulateV1, receipt regeneration, deferred commitment updates, collapse tracing and trie traces are unsupported and refuse rather than degrade; debug_executionWitness is supported and verifies each witness by stateless re-execution before returning it", + "hash", commitment.PBinHashSuiteName()) + } + var chainConfig *chain.Config var genesis *types.Block if err := rawChainDB.Update(context.Background(), func(tx kv.RwTx) error { diff --git a/node/ethconfig/config.go b/node/ethconfig/config.go index cf19285747d..ef536fc7d6a 100644 --- a/node/ethconfig/config.go +++ b/node/ethconfig/config.go @@ -327,6 +327,8 @@ type Sync struct { KeepExecutionProofs bool ExperimentalParallelCommitment bool ExperimentalStreamingCommitment bool + ExperimentalBinCommitment bool + BinCommitmentHash string PersistReceiptsCacheV2 bool SnapshotDownloadToBlock uint64 // exclusive [0,toBlock) } diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index 61c057eac7c..02f42f97148 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -56,6 +56,11 @@ type RecordingState struct { // createdCodeHashes holds code hashes written in-block; a pre-state read of a hash // already created in-block is redundant in the witness (the verifier replays the create). createdCodeHashes map[common.Hash]struct{} + // PreStateHasStorage holds the accounts whose pre-state storage the EIP-7610 + // CREATE-collision check found non-empty. The binary trie commits no + // per-account storage root, so a verifier can only re-derive that answer from + // a proof of the account's storage zone (see accessedState.pbinStorageProbes). + PreStateHasStorage map[common.Address]struct{} //HashedCodes map[common.Hash][]byte // set of code hashes seen during execution, used to avoid duplicate code entries in result.Codes @@ -90,6 +95,7 @@ func NewRecordingState(inner state.StateReader) *RecordingState { AccessedCode: make(map[common.Address][]byte), PreStateCode: make(map[common.Address][]byte), createdCodeHashes: make(map[common.Hash]struct{}), + PreStateHasStorage: make(map[common.Address]struct{}), accountOverlay: make(map[common.Address]*accounts.Account), storageOverlay: make(map[common.Address]map[common.Hash]uint256.Int), codeOverlay: make(map[common.Address][]byte), @@ -237,6 +243,9 @@ func (s *RecordingState) HasStorage(address accounts.Address) (bool, error) { return false, nil } has, err := s.inner.HasStorage(address) + if err == nil && has { + s.PreStateHasStorage[addr] = struct{}{} + } if s.tracing(addr) { fmt.Printf("[TRACE] HasStorage %s -> inner %v (err=%v)\n", addr.Hex(), has, err) } @@ -585,22 +594,37 @@ const ( witnessModeCanonical ) -// resolveWitnessMode resolves the witness mode from the request param; absent, defaults to legacy. -// An explicit param value other than "legacy"/"canonical" is rejected. -func resolveWitnessMode(modeParam *string) (witnessMode, error) { +// errWitnessCanonicalHexOnly rejects an explicit canonical request under the binary +// trie. The legacy/canonical split is an MPT distinction (empty nodes, minimum +// siblings); bin has a single witness form, which the legacy default names. +var errWitnessCanonicalHexOnly = errors.New("canonical witness mode is hex-only: the binary trie has a single witness form") + +// resolveWitnessMode resolves the witness mode from the request param; absent or empty, +// it defaults to legacy. An explicit param value other than "legacy"/"canonical" is +// rejected, as is canonical under the binary trie. +func resolveWitnessMode(modeParam *string, binTrie bool) (witnessMode, error) { if modeParam == nil { return witnessModeLegacy, nil } switch *modeParam { - case "legacy": + case "", "legacy": return witnessModeLegacy, nil case "canonical": + if binTrie { + return witnessModeLegacy, errWitnessCanonicalHexOnly + } return witnessModeCanonical, nil default: return witnessModeLegacy, fmt.Errorf("invalid witness mode %q: must be \"legacy\" or \"canonical\"", *modeParam) } } +// binCommitmentTrie reports whether the datadir runs the EIP-8297 binary commitment +// trie, which skips the witness pipeline's MPT-shaped phases. +func binCommitmentTrie() bool { + return execctx.PickTrieVariant() == commitment.VariantBinPatriciaTrie +} + // buildAccessedState re-executes a block against a recording historical-state reader // and rolls the recorded accesses into an accessedState. The returned accessedBlockHashes // are the block numbers the BLOCKHASH opcode resolved during execution. @@ -714,7 +738,7 @@ func (api *BaseAPI) buildAccessedState( // It executes a block using a historical state reader, records all state accesses // (accounts, storage, code), and builds merkle proofs for the accessed keys. func (api *DebugAPIImpl) ExecutionWitness(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, mode *string) (*ExecutionWitnessResult, error) { - resolvedMode, err := resolveWitnessMode(mode) + resolvedMode, err := resolveWitnessMode(mode, binCommitmentTrie()) if err != nil { return nil, err } @@ -871,6 +895,7 @@ func (api *DebugAPIImpl) buildWitnessResultHeadCapture(ctx context.Context, comm // hc redirects only the commitment-domain reads to a pinned parent snapshot (head-capture); // nil is the durable-history path. func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalTx, hc *headCaptureSource, info *witnessBlockInfo, mode witnessMode) (*ExecutionWitnessResult, error) { + binTrie := binCommitmentTrie() blockNum := info.BlockNum block := info.Block firstTxNumInBlock := info.FirstTxNumInBlock @@ -898,9 +923,10 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT // Build merkle proofs for all accessed accounts // Use the proof infrastructure from the commitment context. - // Witness generation requires the sequential HexPatriciaHashed (Witness() - // type-asserts it); the parallel trie cannot serve it. - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + // Witness capture is served by the sequential HexPatriciaHashed and by + // PBinPatriciaHashed, so bin is allowed through; only the parallel trie + // cannot serve it and is demoted. + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithoutParallelCommitment()) if err != nil { return nil, err } @@ -936,14 +962,14 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT siblingPaths, err := detectCollapseSiblings(ctx, tx, hc, domains, sdCtx, firstTxNumInBlock, endTxNum, blockNum, parentNum, - block.Root(), accessed, mode) + block.Root(), accessed, mode, binTrie) if err != nil { return nil, err } // Materialize exclusion-proof branches for strict sparse-trie verifiers in legacy/default // mode; canonical mode stays minimal to match the reference witness. - nodes, err := buildWitnessTrie(ctx, tx, hc, domains, sdCtx, firstTxNumInBlock, expectedParentRoot, siblingPaths, accessed, mode != witnessModeCanonical) + nodes, err := buildWitnessTrie(ctx, tx, hc, domains, sdCtx, firstTxNumInBlock, expectedParentRoot, siblingPaths, accessed, mode != witnessModeCanonical, binTrie) if err != nil { return nil, err } @@ -960,21 +986,11 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT if !ok { return nil, fmt.Errorf("engine does not support full rules.Engine interface") } - if err := api.verifyWitnessStateless(ctx, tx, result, block, fullEngine); err != nil { + if err := api.verifyWitnessStateless(ctx, tx, result, block, fullEngine, binTrie, expectedParentRoot); err != nil { return nil, fmt.Errorf("%w: %w", errWitnessVerifyFailed, err) } - // legacy carries the empty storage-trie node (0x80) once when some account has an - // empty storage root (EmptyRoot appears only as an account-leaf storage-root field); - // canonical omits it. Added after stateless verification, which rejects the bare node. - if mode == witnessModeLegacy { - for _, node := range result.State { - if bytes.Contains(node, trie.EmptyRoot[:]) { - result.State = append(result.State, hexutil.Bytes{0x80}) - break - } - } - } + result.State = appendLegacyEmptyStorageNode(result.State, mode, binTrie) // Sort after verifyWitnessStateless: RLPDecode treats result.State[0] as the trie root. slices.SortFunc(result.State, func(a, b hexutil.Bytes) int { @@ -984,6 +1000,22 @@ func (api *DebugAPIImpl) buildWitnessResult(ctx context.Context, tx kv.TemporalT return result, nil } +// appendLegacyEmptyStorageNode appends the empty storage-trie node (0x80) once when some +// account leaf carries an empty storage root (EmptyRoot appears only as an account-leaf +// storage-root field). It is an MPT artifact: canonical mode omits it and the binary trie +// has no such node. Called after stateless verification, which rejects the bare node. +func appendLegacyEmptyStorageNode(nodes []hexutil.Bytes, mode witnessMode, binTrie bool) []hexutil.Bytes { + if mode != witnessModeLegacy || binTrie { + return nodes + } + for _, node := range nodes { + if bytes.Contains(node, trie.EmptyRoot[:]) { + return append(nodes, hexutil.Bytes{0x80}) + } + } + return nodes +} + // accessedState summarizes everything the witness needs from a recorded execution: // the deduplicated set of accessed accounts/storage/code addresses, the sorted code // blobs that go into result.Codes, and the pre-state code reads that feed witness @@ -997,6 +1029,30 @@ type accessedState struct { SortedCodes []hexutil.Bytes CodeReads map[common.Hash]witnesstypes.CodeWithHash Deleted map[common.Address]struct{} + // ModifiedCode is the code the block writes, per address. The binary trie + // commits code, so a witness for it has to cover the chunk keys these imply. + ModifiedCode map[common.Address][]byte + // StorageZoneProbes names the accounts whose storage the CREATE-collision + // check read out of pre-state (RecordingState.PreStateHasStorage). + StorageZoneProbes map[common.Address]struct{} +} + +// pbinStorageProbes returns one plain storage key per account the +// CREATE-collision check found storage on. Touching them brings those accounts' +// storage zones into the witness, without which a binary-trie verifier reads a +// zone slot as no storage at all: the tree commits no per-account storage root, +// and the zone sits off the proof path the account's own leaves lie on. +func (a *accessedState) pbinStorageProbes() [][]byte { + probe := commitment.PBinStorageZoneProbeSlot() + keys := make([][]byte, 0, len(a.StorageZoneProbes)) + for addr := range a.StorageZoneProbes { + key := make([]byte, 0, len(addr)+len(probe)) + key = append(key, addr[:]...) + key = append(key, probe[:]...) + keys = append(keys, key) + } + slices.SortFunc(keys, bytes.Compare) + return keys } // isEmpty reports whether no accounts, storage slots, or code addresses were touched. @@ -1042,8 +1098,9 @@ func (a *accessedState) touchNonZeroKeys(sdCtx *commitmentdb.SharedDomainsCommit // touchAll touches every accessed account, storage slot, and code address on the // commitment context. Order matches the original inline implementation: accounts -// first, then storage, then code. -func (a *accessedState) touchAll(sdCtx *commitmentdb.SharedDomainsCommitmentContext) { +// first, then storage, then code. Bin additionally touches the storage-zone +// probes; hex needs none, since an MPT account leaf carries its storage root. +func (a *accessedState) touchAll(sdCtx *commitmentdb.SharedDomainsCommitmentContext, binTrie bool) { for addr := range a.Addresses { sdCtx.TouchKey(kv.AccountsDomain, string(addr[:]), nil) } @@ -1056,6 +1113,11 @@ func (a *accessedState) touchAll(sdCtx *commitmentdb.SharedDomainsCommitmentCont for addr := range a.CodeAddrs { sdCtx.TouchKey(kv.CodeDomain, string(addr[:]), nil) } + if binTrie { + for _, probe := range a.pbinStorageProbes() { + sdCtx.TouchKey(kv.StorageDomain, string(probe), nil) + } + } } // collectAccessedState rolls the RecordingState maps into an accessedState. @@ -1063,17 +1125,24 @@ func (a *accessedState) touchAll(sdCtx *commitmentdb.SharedDomainsCommitmentCont // verifier re-derives in-block-created code by replaying the transactions. func collectAccessedState(rs *RecordingState, mode witnessMode) *accessedState { out := &accessedState{ - Addresses: make(map[common.Address]struct{}), - Storage: make(map[common.Address]map[common.Hash]struct{}), - CodeAddrs: make(map[common.Address]struct{}), - SortedCodes: []hexutil.Bytes{}, - WitnessKeys: []hexutil.Bytes{}, - CodeReads: make(map[common.Hash]witnesstypes.CodeWithHash), - Deleted: make(map[common.Address]struct{}), + Addresses: make(map[common.Address]struct{}), + Storage: make(map[common.Address]map[common.Hash]struct{}), + CodeAddrs: make(map[common.Address]struct{}), + SortedCodes: []hexutil.Bytes{}, + WitnessKeys: []hexutil.Bytes{}, + CodeReads: make(map[common.Hash]witnesstypes.CodeWithHash), + ModifiedCode: make(map[common.Address][]byte), + Deleted: make(map[common.Address]struct{}), + + StorageZoneProbes: make(map[common.Address]struct{}, len(rs.PreStateHasStorage)), } + for addr := range rs.DeletedAccounts { out.Deleted[addr] = struct{}{} } + for addr := range rs.PreStateHasStorage { + out.StorageZoneProbes[addr] = struct{}{} + } readAddresses, readStorageKeys := rs.GetAccessedKeys() writeAddresses, writeStorageKeys := rs.GetModifiedKeys() @@ -1212,8 +1281,9 @@ func collectAccessedState(rs *RecordingState, mode witnessMode) *accessedState { for addr := range preCode { out.CodeAddrs[addr] = struct{}{} } - for addr := range modCode { + for addr, code := range modCode { out.CodeAddrs[addr] = struct{}{} + out.ModifiedCode[addr] = code } return out @@ -1223,6 +1293,12 @@ func collectAccessedState(rs *RecordingState, mode witnessMode) *accessedState { // (commitment from parent state, plain state from block end) and returns the sibling // paths the trie collapses through. The witness build must touch them, else collapsed- // sibling data is missing and stateless re-execution diverges from the root. +// +// The whole phase is hex-only. The binary trie does collapse branches, but it needs no +// second pass to find them: its pruner keeps the sibling hanging off every branch a +// proved key descends, so the survivor of any collapse is already in the witness. Both +// tools this phase uses refuse bin anyway — SetCollapseTracer panics and +// BranchChildCount is keyed by a hex nibble prefix. func detectCollapseSiblings( ctx context.Context, tx kv.TemporalTx, @@ -1233,7 +1309,12 @@ func detectCollapseSiblings( expectedBlockRoot common.Hash, accessed *accessedState, mode witnessMode, + binTrie bool, ) (siblingPaths [][]byte, err error) { + if binTrie { + return nil, nil + } + // Set up split reader: commitment from block beginning (durable) or the pinned // parent snapshot (head-capture), plain state from block end. withHistory=false // so branch updates are written using PutBranch(). @@ -1314,7 +1395,16 @@ func buildWitnessTrie( siblingPaths [][]byte, accessed *accessedState, produceExclusionProofs bool, + binTrie bool, ) (encodedNodes []hexutil.Bytes, err error) { + // TouchHashedKey records a hashed path with an empty plain key, which the bin update + // stream cannot resolve. Bin carries its collapse survivors through the pruner instead, + // so detectCollapseSiblings returns none for it and one arriving here is a bug to + // surface, not a case to serve. + if binTrie && len(siblingPaths) > 0 { + return nil, fmt.Errorf("binary trie witness got %d collapse sibling paths; the binary trie names none", len(siblingPaths)) + } + encodedNodes = []hexutil.Bytes{} sdCtx.SetCustomHistoryStateReader(trieReaderFor(hc, tx, firstTxNumInBlock)) @@ -1322,7 +1412,24 @@ func buildWitnessTrie( return nil, fmt.Errorf("failed to reset commitment for regular witness: %w", err) } - accessed.touchAll(sdCtx) + accessed.touchAll(sdCtx, binTrie) + + // The pass walks the parent state, which holds neither the code the block + // deploys nor any sign of which accounts it removed — and under bin both + // decide which keys the block touches. + if binTrie { + block := commitment.PBinWitnessBlock{ + Code: make(map[string][]byte, len(accessed.ModifiedCode)), + Removed: make(map[string]struct{}, len(accessed.Deleted)), + } + for addr, code := range accessed.ModifiedCode { + block.Code[string(addr[:])] = code + } + for addr := range accessed.Deleted { + block.Removed[string(addr[:])] = struct{}{} + } + sdCtx.SetWitnessBlock(block) + } if len(siblingPaths) > 0 { log.Debug("[debug_executionWitness] detected sibling paths", "count", len(siblingPaths)) @@ -1447,17 +1554,18 @@ func (api *BaseAPI) collectAccessedHeaders( return headers, byNumber, nil } -// verifyWitnessStateless optionally re-executes the block statelessly against the -// generated witness and asserts the resulting state root matches. Verification is -// a no-op when ERIGON_WITNESS_NO_VERIFY=true (it roughly doubles execution cost). +// verifyWitnessStateless re-executes the block statelessly against the generated +// witness and asserts the resulting state root matches. func (api *DebugAPIImpl) verifyWitnessStateless( ctx context.Context, tx kv.TemporalTx, result *ExecutionWitnessResult, block *types.Block, fullEngine rules.Engine, + binTrie bool, + parentRoot common.Hash, ) error { - if dbg.EnvBool("ERIGON_WITNESS_NO_VERIFY", false) { + if witnessVerifySkipped(binTrie) { return nil } @@ -1466,7 +1574,51 @@ func (api *DebugAPIImpl) verifyWitnessStateless( return fmt.Errorf("failed to get chain config: %w", err) } - newStateRoot, stateless, err := execBlockStatelessly(result, block, chainCfg, fullEngine) + return verifyWitnessAgainstBlock(ctx, result, block, parentRoot, chainCfg, fullEngine, binTrie) +} + +// witnessVerifySkipped reports whether ERIGON_WITNESS_NO_VERIFY may turn the +// stateless gate off. Under bin it never may: binary witnesses have no external +// conformance oracle, so re-execution is the only correctness evidence there is, +// while hex's opt-out exists only to save the roughly doubled execution cost. +func witnessVerifySkipped(binTrie bool) bool { + return !binTrie && dbg.EnvBool("ERIGON_WITNESS_NO_VERIFY", false) +} + +// verifyWitnessAgainstBlock re-executes the block from the witness alone and +// asserts it reaches the header's post-state root, then that keys[] carries a +// preimage for every leaf the re-execution resolved. The two variants share the +// replay and differ only in how a leaf resolves and how the root is merkelized; +// bin needs parentRoot because its decoder is told its root rather than deriving +// it from the node set. +func verifyWitnessAgainstBlock( + ctx context.Context, + result *ExecutionWitnessResult, + block *types.Block, + parentRoot common.Hash, + chainCfg *chain.Config, + fullEngine rules.Engine, + binTrie bool, +) error { + var ( + newStateRoot common.Hash + usedAddrs map[common.Address]struct{} + usedSlots map[common.Hash]struct{} + err error + ) + if binTrie { + var stateless *pbinWitnessStateless + newStateRoot, stateless, err = pbinExecBlockStatelessly(ctx, result, block, parentRoot, chainCfg, fullEngine) + if stateless != nil { + usedAddrs, usedSlots = stateless.usedTrieAddrs, stateless.usedTrieSlots + } + } else { + var stateless *witnessStateless + newStateRoot, stateless, err = execBlockStatelessly(result, block, chainCfg, fullEngine) + if stateless != nil { + usedAddrs, usedSlots = stateless.usedTrieAddrs, stateless.usedTrieSlots + } + } if err != nil { return fmt.Errorf("[debug_executionWitness] stateless block execution failed: %w", err) } @@ -1476,8 +1628,8 @@ func (api *DebugAPIImpl) verifyWitnessStateless( return fmt.Errorf("[debug_executionWitness] state root mismatch after stateless execution : got %x, expected %x", newStateRoot, expectedRoot) } - if stateless != nil { - if err := checkWitnessKeysComplete(stateless.usedTrieAddrs, stateless.usedTrieSlots, result.Keys); err != nil { + if usedAddrs != nil || usedSlots != nil { + if err := checkWitnessKeysComplete(usedAddrs, usedSlots, result.Keys); err != nil { return fmt.Errorf("[debug_executionWitness] %w", err) } } @@ -2057,6 +2209,30 @@ func execBlockStatelessly(result *ExecutionWitnessResult, block *types.Block, ch // common.HexToAddress("0x8863786beBE8eB9659DF00b49f8f1eeEc7e2C8c1"), }) + if err := replayBlockOverWitness(result, block, chainConfig, engine, stateless); err != nil { + return common.Hash{}, stateless, err + } + + // Finalize and compute the resulting state root + newStateRoot, err := stateless.Finalize() + if err != nil { + return common.Hash{}, stateless, fmt.Errorf("[statelessExec] stateless.Finalize() failed: %w", err) + } + return newStateRoot, stateless, nil +} + +// statelessWitnessState is the reader/writer seam a witness re-execution runs +// against. Hex and bin resolve a leaf and merkelize differently but replay a +// block identically, so the replay itself is shared. +type statelessWitnessState interface { + state.StateReader + state.StateWriter +} + +// replayBlockOverWitness drives the block through the EVM against a witness-backed +// reader/writer. It stops short of the post-state root, which each variant computes +// its own way. +func replayBlockOverWitness(result *ExecutionWitnessResult, block *types.Block, chainConfig *chain.Config, engine rules.Engine, stateless statelessWitnessState) error { // Create the in-block state with the witness stateless as reader ibs := state.New(stateless) defer ibs.Close() @@ -2074,18 +2250,18 @@ func execBlockStatelessly(result *ExecutionWitnessResult, block *types.Block, ch systemCallCustom := func(contract accounts.Address, data []byte, ibState *state.IntraBlockState, hdr *types.Header, constCall bool) ([]byte, error) { return protocol.SysCallContract(contract, data, chainConfig, ibState, hdr, engine, constCall, vm.Config{}) } - if err = engine.Initialize(chainConfig, nil /* chainReader */, header, ibs, systemCallCustom, log.Root(), nil); err != nil { - return common.Hash{}, stateless, fmt.Errorf("verification: failed to initialize block: %w", err) + if err := engine.Initialize(chainConfig, nil /* chainReader */, header, ibs, systemCallCustom, log.Root(), nil); err != nil { + return fmt.Errorf("verification: failed to initialize block: %w", err) } - if err = ibs.FinalizeTx(blockRules, stateless); err != nil { - return common.Hash{}, stateless, fmt.Errorf("verification: failed to finalize engine.Initialize tx: %w", err) + if err := ibs.FinalizeTx(blockRules, stateless); err != nil { + return fmt.Errorf("verification: failed to finalize engine.Initialize tx: %w", err) } // Execute all transactions in the block for txIndex, txn := range block.Transactions() { msg, err := txn.AsMessage(*signer, header.BaseFee, blockRules) if err != nil { - return common.Hash{}, stateless, fmt.Errorf("[statelessExec] failed to convert tx %d to message: %w", txIndex, err) + return fmt.Errorf("[statelessExec] failed to convert tx %d to message: %w", txIndex, err) } txCtx := protocol.NewEVMTxContext(msg) @@ -2095,14 +2271,13 @@ func execBlockStatelessly(result *ExecutionWitnessResult, block *types.Block, ch ibs.SetTxContext(blockNum, txIndex) // Apply the message - gasBailout must be false to properly deduct gas from sender - _, err = protocol.ApplyMessage(evm, msg, gp, true /* refunds */, false /* gasBailout */, engine) - if err != nil { - return common.Hash{}, stateless, fmt.Errorf("[statelessExec] failed to apply tx %d: %w", txIndex, err) + if _, err = protocol.ApplyMessage(evm, msg, gp, true /* refunds */, false /* gasBailout */, engine); err != nil { + return fmt.Errorf("[statelessExec] failed to apply tx %d: %w", txIndex, err) } // Finalize tx - state changes go to the witness stateless if err = ibs.FinalizeTx(blockRules, stateless); err != nil { - return common.Hash{}, stateless, fmt.Errorf("[statelessExec] failed to finalize tx %d: %w", txIndex, err) + return fmt.Errorf("[statelessExec] failed to finalize tx %d: %w", txIndex, err) } } @@ -2117,20 +2292,13 @@ func execBlockStatelessly(result *ExecutionWitnessResult, block *types.Block, ch // only Bor and AuRa engine use ChainReader. And the ChainReader is only used to read headers. This means their // witness may need to be augmented with headers accessed during their engine.Finalize(). This is something that // can be implemented later. For now use ChainReader = nil, as this is sufficient for Ethereum. - _, err = engine.Finalize(chainConfig, types.CopyHeader(header), ibs, block.Uncles(), statelessReceipts, block.Withdrawals(), nil /* chainReader */, syscall, false /*skipReceiptsEval*/, log.Root()) - if err != nil { - return common.Hash{}, stateless, fmt.Errorf("[statelessExec] engine.Finalize failed: %w", err) + if _, err := engine.Finalize(chainConfig, types.CopyHeader(header), ibs, block.Uncles(), statelessReceipts, block.Withdrawals(), nil /* chainReader */, syscall, false /*skipReceiptsEval*/, log.Root()); err != nil { + return fmt.Errorf("[statelessExec] engine.Finalize failed: %w", err) } - err = ibs.CommitBlock(blockRules, stateless) - if err != nil { - return common.Hash{}, stateless, fmt.Errorf("[statelessExec] ibs.CommitBlock() failed : %w", err) + if err := ibs.CommitBlock(blockRules, stateless); err != nil { + return fmt.Errorf("[statelessExec] ibs.CommitBlock() failed : %w", err) } - // Finalize and compute the resulting state root - newStateRoot, err := stateless.Finalize() - if err != nil { - return common.Hash{}, stateless, fmt.Errorf("[statelessExec] stateless.Finalize() failed: %w", err) - } - return newStateRoot, stateless, nil + return nil } diff --git a/rpc/jsonrpc/debug_execution_witness_test.go b/rpc/jsonrpc/debug_execution_witness_test.go index 7ce9b271d2a..5a99a233e50 100644 --- a/rpc/jsonrpc/debug_execution_witness_test.go +++ b/rpc/jsonrpc/debug_execution_witness_test.go @@ -32,6 +32,7 @@ import ( "github.com/erigontech/erigon/db/kv/prune" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/commitment/commitmentdb" "github.com/erigontech/erigon/execution/protocol/params" "github.com/erigontech/erigon/execution/types" @@ -44,6 +45,7 @@ import ( // map, used to exercise RecordingState predicates without a database. type fakeStateReader struct { accounts map[common.Address]*accounts.Account + storage map[common.Address]bool } func (r *fakeStateReader) ReadAccountData(address accounts.Address) (*accounts.Account, error) { @@ -55,7 +57,9 @@ func (r *fakeStateReader) ReadAccountDataForDebug(address accounts.Address) (*ac func (r *fakeStateReader) ReadAccountStorage(address accounts.Address, key accounts.StorageKey) (uint256.Int, bool, error) { return uint256.Int{}, false, nil } -func (r *fakeStateReader) HasStorage(address accounts.Address) (bool, error) { return false, nil } +func (r *fakeStateReader) HasStorage(address accounts.Address) (bool, error) { + return r.storage[address.Value()], nil +} func (r *fakeStateReader) ReadAccountCode(address accounts.Address) ([]byte, error) { return nil, nil } func (r *fakeStateReader) ReadAccountCodeSize(address accounts.Address) (int, error) { return 0, nil } func (r *fakeStateReader) ReadAccountIncarnation(address accounts.Address) (uint64, error) { @@ -367,7 +371,7 @@ func TestResolveWitnessMode(t *testing.T) { str := func(s string) *string { return &s } t.Run("param selects mode", func(t *testing.T) { - got, err := resolveWitnessMode(str("legacy")) + got, err := resolveWitnessMode(str("legacy"), false) if err != nil { t.Fatal(err) } @@ -375,7 +379,7 @@ func TestResolveWitnessMode(t *testing.T) { t.Errorf("param legacy should resolve to legacy mode, got %v", got) } - got, err = resolveWitnessMode(str("canonical")) + got, err = resolveWitnessMode(str("canonical"), false) if err != nil { t.Fatal(err) } @@ -385,13 +389,13 @@ func TestResolveWitnessMode(t *testing.T) { }) t.Run("unknown param rejected", func(t *testing.T) { - if _, err := resolveWitnessMode(str("bogus")); err == nil { + if _, err := resolveWitnessMode(str("bogus"), false); err == nil { t.Error("expected error for unknown mode param") } }) t.Run("legacy default when param nil", func(t *testing.T) { - got, err := resolveWitnessMode(nil) + got, err := resolveWitnessMode(nil, false) if err != nil { t.Fatal(err) } @@ -651,3 +655,32 @@ func TestGetWitnessHeadCaptureOutOfWindowWhenPruned(t *testing.T) { _, err := api.GetWitness(ctx, rpc.BlockNumberOrHash{BlockNumber: &bn}) require.ErrorIs(t, err, errWitnessOutOfWindow) } + +// TestCollectAccessedState_PBinStorageZoneProbes asserts the CREATE-collision +// check leaves a storage-zone probe behind for an account whose pre-state +// storage it found. The binary trie commits no per-account storage root, so +// without the probe the witness carries nothing that can answer EIP-7610 for a +// slot outside the account header and the replay re-runs the CREATE. +func TestCollectAccessedState_PBinStorageZoneProbes(t *testing.T) { + withStorage := common.HexToAddress("0x1111111111111111111111111111111111111111") + without := common.HexToAddress("0x2222222222222222222222222222222222222222") + + inner := &fakeStateReader{ + accounts: map[common.Address]*accounts.Account{withStorage: {Nonce: 1}, without: {Nonce: 1}}, + storage: map[common.Address]bool{withStorage: true}, + } + rs := NewRecordingState(inner) + for _, addr := range []common.Address{withStorage, without} { + if _, err := rs.HasStorage(accounts.InternAddress(addr)); err != nil { + t.Fatal(err) + } + } + + accessed := collectAccessedState(rs, witnessModeCanonical) + probes := accessed.pbinStorageProbes() + + probe := commitment.PBinStorageZoneProbeSlot() + want := append(bytes.Clone(withStorage[:]), probe[:]...) + require.Equal(t, [][]byte{want}, probes, + "the probe belongs to the account whose pre-state storage the collision check found, and to no other") +} diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index f1cd0e2fb43..3a1578d43b9 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -481,7 +481,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co return nil, fmt.Errorf("header not found for block %d", blockNrOrHash.BlockNumber.Uint64()) } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } @@ -780,7 +780,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO it.Close() } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } @@ -789,7 +789,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO siblingPaths, err := detectCollapseSiblings(ctx, tx, nil, domains, sdCtx, firstTxNumInBlock, endTxNum, blockNr, parentNum, - block.Root(), accessed, witnessModeLegacy) + block.Root(), accessed, witnessModeLegacy, false /* binTrie: WithHexCommitmentOnly refuses bin above */) if err != nil { return nil, err } @@ -799,7 +799,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO return nil, fmt.Errorf("failed to reset commitment for witness: %w", err) } - accessed.touchAll(sdCtx) + accessed.touchAll(sdCtx, false /* binTrie: WithHexCommitmentOnly refuses bin above */) for _, siblingPath := range siblingPaths { sdCtx.TouchHashedKey(siblingPath) } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 2489a08993c..72233467fc9 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -164,7 +164,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block return nil, err } - sharedDomains, err := execctx.NewSharedDomains(ctx, tx, api.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sharedDomains, err := execctx.NewSharedDomains(ctx, tx, api.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/pbin_hex_only_test.go b/rpc/jsonrpc/pbin_hex_only_test.go new file mode 100644 index 00000000000..fbf9ae2a3cb --- /dev/null +++ b/rpc/jsonrpc/pbin_hex_only_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/rpcdaemon/rpcdaemontest" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/node/ethconfig" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/ethapi" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// Proof and simulation both recompute state with the hex trie, so they must refuse a +// bin datadir instead of reading its bit-path branch records as hex ones. +func TestPBinGetProofRefusesBin(t *testing.T) { + // No t.Parallel: mutates process-global statecfg flags. + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + cfg := &rpccfg.EthApiConfig{ + GasCap: 5000000, + FeeCap: ethconfig.Defaults.RPCTxFeeCap, + ReturnDataLimit: 100_000, + MaxGetProofRewindBlockCount: 1, + SubscribeLogsChannelSize: 128, + RpcTxSyncDefaultTimeout: 20 * time.Second, + RpcTxSyncMaxTimeout: 1 * time.Minute, + } + api := NewEthAPI(newBaseApiForTest(m), m.DB, nil, nil, nil, cfg, log.New()) + + // The chain above is built on the hex trie; only the proof call runs under bin. + orig := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = orig }) + statecfg.ExperimentalBinCommitment = true + + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + _, err := api.GetProof(t.Context(), common.HexToAddress("0x71562b71999873db5b286df957af199ec94617f7"), nil, &latest) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) + + req := SimulationRequest{BlockStateCalls: []SimulatedBlock{{Calls: []ethapi.CallArgs{{}}}}} + _, err = api.SimulateV1(t.Context(), req, latest) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) +} diff --git a/rpc/jsonrpc/pbin_witness_altspec_test.go b/rpc/jsonrpc/pbin_witness_altspec_test.go new file mode 100644 index 00000000000..5b8066ecfb5 --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_altspec_test.go @@ -0,0 +1,240 @@ +package jsonrpc + +// What a binary witness would weigh if code were not committed as chunk leaves: +// never chunk; every contract ships as a blob, as hex does. +// +// The variant is measured, not modelled: the chunk keys are dropped from the +// proved set and the real pruner re-runs, so the branches that existed only to +// bind those chunks go too. The blob term is the contract's own bytecode, the +// same bytes hex carries in Codes. +// +// The variant is not a proposal and its root differs from the spec's — this +// prices the choice, it does not implement it. + +import ( + "bytes" + "fmt" + "maps" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +type pbinAltRow struct { + name, hex string + size int + chunks int + + hexProof, hexCode, hexTotal int + hexNodes int + + binNodes, binTotal int + leanNodes, leanProof int // re-pruned, code-chunk keys dropped +} + +// blob is what the contract's bytecode weighs when a witness ships it whole. +func (r pbinAltRow) blob() int { return r.size } + +// noChunk drops chunking outright. +func (r pbinAltRow) noChunk() int { return r.leanProof + r.blob() } + +func TestPBinWitnessNoCodeZone(t *testing.T) { + withCommitmentHistory(t) + n := len(pbinGranCases) + rows := make([]pbinAltRow, n) + for i := range rows { + rows[i].name = pbinGranCases[i].name + rows[i].size = pbinGranCases[i].size + rows[i].chunks = pbinGranCases[i].chunks + } + + t.Run("hex", func(t *testing.T) { + c, _ := pbinGranChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i := range rows { + w := pbinWitnessOf(t, api, uint64(n+i+1)) + r := &rows[i] + r.hexProof = sumBytes(w.State) + sumBytes(w.Headers) + r.hexCode = sumBytes(w.Codes) + r.hexTotal = r.hexProof + r.hexCode + r.hexNodes = len(w.State) + } + }) + + t.Run("bin", func(t *testing.T) { + withBinCommitmentDatadir(t) + c, _ := pbinGranChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i := range rows { + num := uint64(n + i + 1) + w := pbinWitnessOf(t, api, num) + r := &rows[i] + + nodes := make([][]byte, 0, len(w.State)) + keep := make([][]byte, 0, len(w.State)) + for _, node := range w.State { + nodes = append(nodes, node) + r.binNodes++ + r.binTotal += len(node) + key := pbinLeafKeyOf(node) + if key != nil && !isCodeChunkKey(key) { + keep = append(keep, key) + } + } + r.binTotal += sumBytes(w.Headers) + + // The RPC sorts result.State, so take the root from the parent header. + root := c.block(t, num-1).Root() + lean, err := commitment.PBinWitnessNodesForKeys(nodes, root[:], keep) + require.NoError(t, err, "%s re-prune", r.name) + r.leanNodes = len(lean) + r.leanProof = sumBytes(w.Headers) + for _, node := range lean { + r.leanProof += len(node) + } + // What the "blob" column costs is only meaningful if dropping the + // chunk keys really drops nodes: the code zone has to be a separable + // part of the witness, not entangled with the account's proof. + require.Less(t, r.leanNodes, r.binNodes, "%s: re-pruning kept every node", r.name) + require.NotZero(t, r.leanNodes, "%s: re-pruning kept nothing", r.name) + } + }) + + t.Log("witness bytes for a call executing 8 bytes, by how code is committed\n" + pbinAltTable(rows)) +} + +// TestPBinWitnessPartialChunks prices chunking's own premise. A blob costs the +// bytecode once; a chunk leaf plus the branch binding it costs ~4.35x the 31 +// bytes it carries, so chunking only wins when a witness can prove a fraction +// of the contract rather than all of it. This sweeps that fraction against the +// real pruner and reports where the two meet. +func TestPBinWitnessPartialChunks(t *testing.T) { + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + n := len(pbinGranCases) + c, _ := pbinGranChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + + out := fmt.Sprintf("%-16s %6s %8s %6s | %9s %8s | %8s %9s\n", + "case", "chunks", "pattern", "proved", "witness B", "vs blob", "blob B", "break-even") + for i, gc := range pbinGranCases { + if gc.chunks < 32 || gc.zeroPad { + continue + } + w := pbinWitnessOf(t, api, uint64(n+i+1)) + nodes := make([][]byte, 0, len(w.State)) + var base, chunkKeys [][]byte + for _, node := range w.State { + nodes = append(nodes, node) + key := pbinLeafKeyOf(node) + if key == nil { + continue + } + if isCodeChunkKey(key) { + chunkKeys = append(chunkKeys, key) + continue + } + base = append(base, key) + } + chunkKeys = pbinContractChunkKeys(t, chunkKeys, gc.chunks) + + root := c.block(t, uint64(n+i)).Root() + size := func(keep [][]byte) int { + lean, err := commitment.PBinWitnessNodesForKeys(nodes, root[:], keep) + require.NoError(t, err) + return sumBytes(w.Headers) + func() int { + total := 0 + for _, node := range lean { + total += len(node) + } + return total + }() + } + blob := size(base) + gc.size + + for _, pattern := range []string{"adjacent", "scattered"} { + var prev int + for _, proved := range []int{1, 8, 32, 64, 128, 256, gc.chunks} { + if proved > gc.chunks || proved == prev { + continue + } + prev = proved + keep := slices.Clone(base) + for j := range proved { + idx := j + if pattern == "scattered" { + idx = j * gc.chunks / proved + } + keep = append(keep, chunkKeys[idx]) + } + got := size(keep) + mark := "" + if got > blob { + mark = " (dearer than a blob)" + } + out += fmt.Sprintf("%-16s %6d %8s %6d | %9d %7.2fx | %8d%s\n", + gc.name, gc.chunks, pattern, proved, got, float64(got)/float64(blob), blob, mark) + } + } + } + t.Log("witness bytes when only part of a contract's chunks are proved\n" + out) +} + +// pbinContractChunkKeys picks the chunk leaves of the contract under test out of +// every chunk leaf the witness carries. The pruner also keeps the sibling +// hanging off each branch it descends, so a chunk leaf of an unrelated contract +// can ride along; the contract's own stems are the ones holding a whole group, +// plus one holding the remainder. +func pbinContractChunkKeys(t *testing.T, keys [][]byte, chunks int) [][]byte { + t.Helper() + stems := map[string][][]byte{} + for _, key := range keys { + stems[string(key[:len(key)-1])] = append(stems[string(key[:len(key)-1])], key) + } + out := make([][]byte, 0, chunks) + for left := chunks; left > 0; left -= pbinCodeGroupChunks { + size := min(left, pbinCodeGroupChunks) + group := "" + for _, stem := range slices.Sorted(maps.Keys(stems)) { + if len(stems[stem]) == size { + group = stem + break + } + } + require.NotEmpty(t, group, "no stem holds a group of %d chunks", size) + out = append(out, stems[group]...) + delete(stems, group) + } + require.Len(t, out, chunks) + slices.SortFunc(out, bytes.Compare) + return out +} + +func pbinAltTable(rows []pbinAltRow) string { + s := fmt.Sprintf("%-16s %7s %6s | %8s | %9s %7s | %9s %7s\n", + "case", "code B", "chunks", "hex tot", + "spec", "/hex", "blob", "/hex") + for _, r := range rows { + ratio := func(v int) float64 { return float64(v) / float64(r.hexTotal) } + s += fmt.Sprintf("%-16s %7d %6d | %8d | %9d %6.2fx | %9d %6.2fx\n", + r.name, r.size, r.chunks, r.hexTotal, + r.binTotal, ratio(r.binTotal), + r.noChunk(), ratio(r.noChunk())) + } + s += "\nproof bytes alone, code blob excluded from both sides:\n" + s += fmt.Sprintf("%-16s %9s %8s | %9s %8s %7s | %8s\n", + "case", "hexProof", "hexNodes", "binProof", "binNodes", "/hex", "blob B") + for _, r := range rows { + s += fmt.Sprintf("%-16s %9d %8d | %9d %8d %6.2fx | %8d\n", + r.name, r.hexProof, r.hexNodes, + r.leanProof, r.leanNodes, float64(r.leanProof)/float64(r.hexProof), r.blob()) + } + return s +} diff --git a/rpc/jsonrpc/pbin_witness_bytesplit_test.go b/rpc/jsonrpc/pbin_witness_bytesplit_test.go new file mode 100644 index 00000000000..255c67f6e94 --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_bytesplit_test.go @@ -0,0 +1,103 @@ +package jsonrpc + +// Measures what code chunking costs a binary witness, two ways: a byte split by +// node kind, and a re-prune with every code-chunk key dropped from the proved +// set so the branches that existed only to bind those chunks go too. +// +// The re-prune is not a proposal — the keys are inside the leaf hashes and the +// root would move. It sizes the cost. +// +// Two limits on what the numbers mean. Proved keys are derived from the leaves +// present in the witness, so absence proofs are undercounted: a blinded node for +// a key with no leaf is off every derived proof path and the re-prune drops it, +// which reads as a code saving on a block holding no code chunks. And the +// re-prune baseline is not the input witness, so the saved column is only a code +// figure for blocks whose code% is non-zero. + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// pbinLeafKeyOf returns the tree key of a leaf preimage, or nil for a branch. +func pbinLeafKeyOf(node []byte) []byte { + if len(node) == 0 || node[0] != 0x00 { + return nil + } + return node[1 : len(node)-32] +} + +// isCodeChunkKey reports whether a tree key names a code chunk: every chunk +// lives in the code zone, content-addressed by code hash. +func isCodeChunkKey(key []byte) bool { + return len(key) > 0 && key[0] == 0x01 +} + +// pbinDelegationSubIndex is the EIP's DELEGATION_LEAF_KEY, unexported by package +// commitment. It stands where CODE_HASH does for a 7702-delegated account. +const pbinDelegationSubIndex = 2 + +func TestPBinWitnessCodeWeight(t *testing.T) { + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + c := buildPBinWitnessChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + + t.Logf("%-6s %-38s %6s %8s %8s %8s %7s %8s %8s %7s", + "block", "shape", "nodes", "total", "leafkey", "branch", "code%", "noCodeN", "noCodeB", "saved") + + var gTotal, gNoCode int + for _, block := range pbinWitnessCorpus { + result := pbinWitnessOf(t, api, block.num) + require.NotEmpty(t, result.State) + + nodes := make([][]byte, 0, len(result.State)) + for _, n := range result.State { + nodes = append(nodes, n) + } + // The RPC sorts result.State bytewise before returning, so the root-first + // contract is gone by here; take the root from the parent header instead. + root := c.block(t, block.num-1).Root() + + var total, leafKey, branch, codeBytes int + var keep [][]byte + for _, n := range nodes { + total += len(n) + key := pbinLeafKeyOf(n) + if key == nil { + branch += len(n) + continue + } + leafKey += len(key) + if isCodeChunkKey(key) { + codeBytes += len(n) + continue + } + keep = append(keep, key) + } + + lean, err := commitment.PBinWitnessNodesForKeys(nodes, root[:], keep) + require.NoError(t, err, "block %d re-prune", block.num) + noCodeBytes := 0 + for _, n := range lean { + noCodeBytes += len(n) + } + + gTotal += total + gNoCode += noCodeBytes + saved := 0.0 + if total > 0 { + saved = 100 * float64(total-noCodeBytes) / float64(total) + } + t.Logf("%-6d %-38s %6d %8d %8d %8d %6.1f%% %8d %8d %6.1f%%", + block.num, block.shape, len(nodes), total, leafKey, branch, + 100*float64(codeBytes)/float64(total), len(lean), noCodeBytes, saved) + } + t.Logf("corpus: %d B with code, %d B without (%.1f%% is code chunking)", + gTotal, gNoCode, 100*float64(gTotal-gNoCode)/float64(gTotal)) +} diff --git a/rpc/jsonrpc/pbin_witness_clone_test.go b/rpc/jsonrpc/pbin_witness_clone_test.go new file mode 100644 index 00000000000..c678eed6491 --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_clone_test.go @@ -0,0 +1,201 @@ +package jsonrpc + +// What duplicated bytecode costs a binary witness. +// +// Every chunk lives in the code zone, keyed by code hash alone, so a block +// calling several clones of one contract proves one shared chunk set; distinct +// contracts of the same size prove one set each. This measures that sharing +// against hex, which stores code by hash and so ships it once either way. + +import ( + "fmt" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jinzhu/copier" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +const ( + pbinCloneCount = 8 + // One chunk past a full group, so sharing is pinned across a group boundary. + pbinCloneChunks = pbinCodeGroupChunks + 1 + pbinCloneSize = 31 * pbinCloneChunks +) + +// pbinCloneChain deploys pbinCloneCount identical contracts and as many +// distinct ones of the same size, then calls each group in one block. +func pbinCloneChain(t *testing.T) (*pbinWitnessChain, uint64, uint64) { + t.Helper() + bankKey, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + require.NoError(t, err) + bankAddress := crypto.PubkeyToAddress(bankKey.PublicKey) + bankFunds, ok := new(big.Int).SetString("100000000000000000000", 10) + require.True(t, ok) + + chainConfig := new(chain.Config) + require.NoError(t, copier.CopyWithOption(chainConfig, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + m := execmoduletester.New(t, + execmoduletester.WithGenesisSpec(&types.Genesis{ + Config: chainConfig, + Alloc: types.GenesisAlloc{bankAddress: {Balance: bankFunds}}, + GasLimit: 60_000_000, + }), + execmoduletester.WithKey(bankKey)) + + signer := types.LatestSignerForChainID(nil) + gasPrice := uint256.NewInt(1_000_000_000) + sign := func(txn *types.LegacyTx) types.Transaction { + t.Helper() + txn.GasPrice = *gasPrice + signed, err := types.SignTx(txn, *signer, bankKey) + require.NoError(t, err) + return signed + } + + runtimeOf := func(distinct bool, i int) []byte { + code := make([]byte, pbinCloneSize) + for j := range code { + code[j] = 0xfe + } + copy(code, pbinStoreRuntime) + if distinct { + code[len(code)-1] = byte(i) + } + return code + } + + clones := make([]common.Address, pbinCloneCount) + distinct := make([]common.Address, pbinCloneCount) + deploys := 2 * pbinCloneCount + + pack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, deploys+2, func(i int, b *blockgen.BlockGen) { + nonce := b.TxNonce(bankAddress) + switch { + case i < pbinCloneCount: + clones[i] = types.CreateAddress(bankAddress, nonce) + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, GasLimit: 12_000_000, Data: pbinDeployCode(runtimeOf(false, i)), + }})) + case i < deploys: + k := i - pbinCloneCount + distinct[k] = types.CreateAddress(bankAddress, nonce) + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, GasLimit: 12_000_000, Data: pbinDeployCode(runtimeOf(true, k)), + }})) + default: + targets := clones + if i == deploys+1 { + targets = distinct + } + for k := range targets { + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: b.TxNonce(bankAddress), To: &targets[k], GasLimit: 200_000, + Data: pbinStoreCalldata(common.HexToHash("0x01"), uint64(k+1)), + }})) + } + } + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(pack)) + return &pbinWitnessChain{m: m, pack: pack}, uint64(deploys + 1), uint64(deploys + 2) +} + +func TestPBinWitnessCloneDedup(t *testing.T) { + withCommitmentHistory(t) + + type row struct { + name string + hexState, hexCodes, hexTot int + binTot, chunkB, branchB int + binNodes int + } + rows := []row{{name: "8 clones"}, {name: "8 distinct"}} + + t.Run("hex", func(t *testing.T) { + c, cloneBlock, distinctBlock := pbinCloneChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i, num := range []uint64{cloneBlock, distinctBlock} { + w := pbinWitnessOf(t, api, num) + rows[i].hexState = sumBytes(w.State) + sumBytes(w.Headers) + rows[i].hexCodes = sumBytes(w.Codes) + rows[i].hexTot = rows[i].hexState + rows[i].hexCodes + } + require.Less(t, rows[0].hexCodes, rows[1].hexCodes, "hex ships duplicated code once") + }) + + t.Run("bin", func(t *testing.T) { + withBinCommitmentDatadir(t) + c, cloneBlock, distinctBlock := pbinCloneChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i, num := range []uint64{cloneBlock, distinctBlock} { + w := pbinWitnessOf(t, api, num) + r := &rows[i] + r.binNodes = len(w.State) + r.binTot = sumBytes(w.State) + sumBytes(w.Headers) + for _, node := range w.State { + key := pbinLeafKeyOf(node) + switch { + case key == nil: + r.branchB += len(node) + case isCodeChunkKey(key): + r.chunkB += len(node) + } + } + } + // Direction stated up front: hex ships duplicated code once by hash, so + // only bin's chunk sharing can keep the clone block anywhere near the + // distinct block — clones must prove fewer bytes than distinct code. + require.Less(t, rows[0].chunkB, rows[1].chunkB, "clones must prove fewer chunk bytes than distinct contracts") + require.Less(t, rows[0].binTot, rows[1].binTot, "clones must prove a smaller witness than distinct contracts") + }) + + out := fmt.Sprintf("%d contracts of %d B (%d chunks, one spilling past a full group), all called in one block\n", + pbinCloneCount, pbinCloneSize, pbinCloneChunks) + out += fmt.Sprintf("%-12s %9s %9s %8s | %8s %7s %8s %9s %9s\n", + "block", "hexState", "hexCodes", "hex tot", "bin tot", "/hex", "binNod", "chunkB", "branchB") + for _, r := range rows { + out += fmt.Sprintf("%-12s %9d %9d %8d | %8d %6.2fx %8d %9d %9d\n", + r.name, r.hexState, r.hexCodes, r.hexTot, + r.binTot, float64(r.binTot)/float64(max(r.hexTot, 1)), r.binNodes, r.chunkB, r.branchB) + } + t.Log("witness cost of duplicated bytecode\n" + out) +} + +// TestPBinWitnessClonesProveOneChunkSet pins what content addressing was +// adopted for: accounts sharing bytecode share its chunk leaves, so the clone +// block proves exactly one chunk set and the distinct block one per contract. +func TestPBinWitnessClonesProveOneChunkSet(t *testing.T) { + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + c, cloneBlock, distinctBlock := pbinCloneChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + + countChunks := func(num uint64) int { + chunks := 0 + for _, node := range pbinWitnessOf(t, api, num).State { + if key := pbinLeafKeyOf(node); key != nil && isCodeChunkKey(key) { + chunks++ + } + } + return chunks + } + + require.Equal(t, pbinCloneChunks, countChunks(cloneBlock), + "%d clones share one content-addressed chunk set", pbinCloneCount) + require.Equal(t, pbinCloneCount*pbinCloneChunks, countChunks(distinctBlock), + "distinct bytecode proves one chunk set per contract") +} diff --git a/rpc/jsonrpc/pbin_witness_deploy_test.go b/rpc/jsonrpc/pbin_witness_deploy_test.go new file mode 100644 index 00000000000..fe2921bc4bd --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_deploy_test.go @@ -0,0 +1,83 @@ +package jsonrpc + +// A block that deploys a contract writes code-chunk leaves. Under bin the +// witness pass walks the parent state, where that code does not exist yet, so +// the chunk keys have to come from the block's own code — otherwise the nodes +// those insertions split go unproved and a stateless verifier cannot reach the +// post-state root. +// +// The second deploy is what makes this bite: the first lands in an empty code +// zone and splits nothing. + +import ( + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jinzhu/copier" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +func TestPBinWitnessConsecutiveDeploys(t *testing.T) { + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + bankKey, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + require.NoError(t, err) + bankAddress := crypto.PubkeyToAddress(bankKey.PublicKey) + bankFunds, ok := new(big.Int).SetString("100000000000000000000", 10) + require.True(t, ok) + + chainConfig := new(chain.Config) + require.NoError(t, copier.CopyWithOption(chainConfig, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + m := execmoduletester.New(t, + execmoduletester.WithGenesisSpec(&types.Genesis{ + Config: chainConfig, + Alloc: types.GenesisAlloc{bankAddress: {Balance: bankFunds}}, + GasLimit: 60_000_000, + }), + execmoduletester.WithKey(bankKey)) + + signer := types.LatestSignerForChainID(nil) + const deploys = 3 + pack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, deploys, func(i int, b *blockgen.BlockGen) { + // Each contract is distinct, so every deploy opens its own code-zone + // stem beside the ones already there. + runtime := make([]byte, 31*(8+i)) + for j := range runtime { + runtime[j] = 0xfe + } + copy(runtime, pbinStoreRuntime) + runtime[len(runtime)-1] = byte(i) + + nonce := b.TxNonce(bankAddress) + txn := &types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, GasLimit: 12_000_000, Data: pbinDeployCode(runtime), + }} + txn.GasPrice = *uint256.NewInt(1_000_000_000) + signed, err := types.SignTx(txn, *signer, bankKey) + require.NoError(t, err) + b.AddTx(signed) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(pack)) + + c := &pbinWitnessChain{m: m, pack: pack} + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + + for block := uint64(1); block <= deploys; block++ { + // pbinWitnessOf fails the test if stateless verification rejects the + // witness, which is the assertion here. + w := pbinWitnessOf(t, api, block) + require.NotEmpty(t, w.State, "block %d", block) + } + +} diff --git a/rpc/jsonrpc/pbin_witness_e2e_test.go b/rpc/jsonrpc/pbin_witness_e2e_test.go new file mode 100644 index 00000000000..13b8aaded9f --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_e2e_test.go @@ -0,0 +1,362 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jinzhu/copier" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/db/kv/rawdbv3" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/state" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/rpccfg" + "github.com/erigontech/erigon/rpc/rpchelper" +) + +// pbinCodeGroupChunks is how many 31-byte chunks one code-zone stem holds: the +// spec's STEM_SUBTREE_WIDTH, unexported by package commitment. +const pbinCodeGroupChunks = 256 + +// pbinStoreRuntime stores calldata[32:64] at slot calldata[0:32], so one deploy +// serves both a non-zero SSTORE and an SSTORE-to-zero. +var pbinStoreRuntime = []byte{ + 0x60, 0x20, 0x35, // PUSH1 32; CALLDATALOAD -> value + 0x60, 0x00, 0x35, // PUSH1 0; CALLDATALOAD -> slot + 0x55, // SSTORE + 0x00, // STOP +} + +// pbinDeployCode wraps runtime code in an initcode that returns it verbatim. +func pbinDeployCode(runtime []byte) []byte { + size := len(runtime) + const prefixLen = 14 + initcode := []byte{ + 0x61, byte(size >> 8), byte(size), // PUSH2 size + 0x60, prefixLen, // PUSH1 codeOffset + 0x60, 0x00, // PUSH1 destOffset + 0x39, // CODECOPY + 0x61, byte(size >> 8), byte(size), // PUSH2 size + 0x60, 0x00, // PUSH1 offset + 0xf3, // RETURN + } + return append(initcode, runtime...) +} + +// pbinStoreCalldata is the (slot, value) pair pbinStoreRuntime writes. +func pbinStoreCalldata(slot common.Hash, value uint64) []byte { + val := uint256.NewInt(value).Bytes32() + return append(slot[:], val[:]...) +} + +type pbinWitnessChain struct { + m *execmoduletester.ExecModuleTester + pack *blockgen.ChainPack + receiver common.Address + small common.Address // code fits one code-zone group + large common.Address // code crosses a code-zone group boundary + slot common.Hash + otherSlot common.Hash +} + +// block returns the block at the given height; height 0 is genesis. +func (c *pbinWitnessChain) block(t *testing.T, num uint64) *types.Block { + t.Helper() + if num == 0 { + return c.m.Genesis + } + require.LessOrEqual(t, num, uint64(len(c.pack.Blocks))) + return c.pack.Blocks[num-1] +} + +// buildPBinWitnessChain generates and imports a chain whose blocks each exercise +// one shape the binary witness has to carry: a plain transfer, a deploy fitting +// one code-zone group, a deploy crossing a group boundary, a non-zero SSTORE, +// an SSTORE-to-zero, a call reading code back across a group boundary, and a +// block with no transactions. +func buildPBinWitnessChain(t *testing.T) *pbinWitnessChain { + t.Helper() + + m, bankKey, bankAddress := fundedBankGenesis(t, chain.TestChainBerlinConfig) + signer := types.LatestSignerForChainID(nil) + gasPrice := uint256.NewInt(1_000_000_000) + + c := &pbinWitnessChain{ + m: m, + receiver: common.HexToAddress("0x00000000000000000000000000000000000f0f0f"), + slot: common.HexToHash("0x01"), + otherSlot: common.HexToHash("0x02"), + } + + overflowing := make([]byte, 31*(pbinCodeGroupChunks+8)) + copy(overflowing, pbinStoreRuntime) + + sign := func(txn *types.LegacyTx) types.Transaction { + t.Helper() + txn.GasPrice = *gasPrice + signed, err := types.SignTx(txn, *signer, bankKey) + require.NoError(t, err) + return signed + } + + pack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 7, func(i int, b *blockgen.BlockGen) { + nonce := b.TxNonce(bankAddress) + switch i { + case 0: // plain transfer, creating the recipient + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, To: &c.receiver, GasLimit: 21_000, Value: *uint256.NewInt(1e9), + }})) + case 1: // deploy whose code fits one code-zone group + c.small = types.CreateAddress(bankAddress, nonce) + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, GasLimit: 200_000, Data: pbinDeployCode(pbinStoreRuntime), + }})) + case 2: // deploy whose code crosses a code-zone group boundary + c.large = types.CreateAddress(bankAddress, nonce) + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, GasLimit: 4_000_000, Data: pbinDeployCode(overflowing), + }})) + case 3: // SSTORE a non-zero value + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, To: &c.small, GasLimit: 100_000, Data: pbinStoreCalldata(c.slot, 42), + }})) + case 4: // SSTORE the same slot back to zero + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, To: &c.small, GasLimit: 100_000, Data: pbinStoreCalldata(c.slot, 0), + }})) + case 5: // call the large contract, so its code is read back across groups + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, To: &c.large, GasLimit: 200_000, Data: pbinStoreCalldata(c.otherSlot, 7), + }})) + case 6: // no transactions + } + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(pack)) + c.pack = pack + + // An under-budgeted transaction fails silently as a reverted receipt and would + // leave the shape it was meant to build out of the chain entirely. + for i, receipts := range pack.Receipts { + for _, receipt := range receipts { + require.EqualValues(t, types.ReceiptStatusSuccessful, receipt.Status, + "transaction in block %d failed", i+1) + } + } + requirePBinChainShape(t, c) + return c +} + +// requirePBinChainShape reads back what each block was written to exercise. A +// block whose transaction ran but did something else — a deploy that no longer +// overflows the header, an SSTORE the runtime code silently skipped — would +// still produce a verifying witness, and the corpus would quietly stop covering +// the case it names. +func requirePBinChainShape(t *testing.T, c *pbinWitnessChain) { + t.Helper() + + tx, err := c.m.DB.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + // A history reader at block N reads the state block N starts from, so the + // effect of block N-1 is read at N. + stateAt := func(blockNum uint64) *state.IntraBlockState { + reader, err := rpchelper.CreateHistoryStateReader(t.Context(), tx, blockNum, 0, rawdbv3.TxNums) + require.NoError(t, err) + st := state.New(reader) + t.Cleanup(st.Close) + return st + } + + balance, err := stateAt(2).GetBalance(accounts.InternAddress(c.receiver)) + require.NoError(t, err) + require.Equal(t, uint64(1e9), balance.Uint64(), "block 1 transfers to a new account") + + smallCode, err := stateAt(3).GetCode(accounts.InternAddress(c.small)) + require.NoError(t, err) + require.Equal(t, pbinStoreRuntime, smallCode) + require.LessOrEqual(t, len(smallCode), 31*pbinCodeGroupChunks, "block 2's code fits one code-zone group") + + largeCode, err := stateAt(4).GetCode(accounts.InternAddress(c.large)) + require.NoError(t, err) + require.Greater(t, len(largeCode), 31*pbinCodeGroupChunks, "block 3's code must cross a group boundary") + + written, err := stateAt(5).GetState(accounts.InternAddress(c.small), accounts.InternKey(c.slot)) + require.NoError(t, err) + require.Equal(t, uint64(42), written.Uint64(), "block 4 writes the slot") + + cleared, err := stateAt(6).GetState(accounts.InternAddress(c.small), accounts.InternKey(c.slot)) + require.NoError(t, err) + require.True(t, cleared.IsZero(), "block 5 stores the slot back to zero") + + viaLargeCode, err := stateAt(7).GetState(accounts.InternAddress(c.large), accounts.InternKey(c.otherSlot)) + require.NoError(t, err) + require.Equal(t, uint64(7), viaLargeCode.Uint64(), "block 6 runs the overflowing contract's code") +} + +func pbinWitnessAPI(t *testing.T, m *execmoduletester.ExecModuleTester) *DebugAPIImpl { + t.Helper() + enableCommitmentHistoryFlag(t, m.DB) + require.True(t, binCommitmentTrie(), "the chain is committed with the binary trie") + return NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) +} + +// requirePBinWitnessVerifies re-executes the block from the witness alone and +// asserts it reaches the header's post-state root. The build applies the same +// gate before returning, so this repeats it deliberately: the assertion belongs +// in the test rather than only in the code under test. +func requirePBinWitnessVerifies(t *testing.T, c *pbinWitnessChain, result *ExecutionWitnessResult, num uint64) { + t.Helper() + + block := c.block(t, num) + parentRoot := c.block(t, num-1).Root() + require.NoError(t, verifyWitnessAgainstBlock(t.Context(), result, block, parentRoot, + c.m.ChainConfig, c.m.Engine, true /* binTrie */), + "block %d witness must re-execute to %x", num, block.Root()) +} + +func pbinWitnessOf(t *testing.T, api *DebugAPIImpl, num uint64) *ExecutionWitnessResult { + t.Helper() + + bn := rpc.BlockNumber(num) + result, err := api.ExecutionWitness(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &bn}, nil) + require.NoError(t, err, "block %d", num) + require.NotNil(t, result) + return result +} + +// TestPBinExecutionWitnessEndToEnd is the end-to-end gate: over a bin-committed +// chain, every block's witness alone re-executes the block to its post-state root. +func TestPBinExecutionWitnessEndToEnd(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + c := buildPBinWitnessChain(t) + api := pbinWitnessAPI(t, c.m) + + for _, tc := range []struct { + num uint64 + name string + }{ + {1, "plain transfer"}, + {2, "deploy within one code-zone group"}, + {3, "deploy crossing a group boundary"}, + {4, "storage write"}, + {5, "SSTORE to zero"}, + {6, "code read across a group boundary"}, + } { + t.Run(tc.name, func(t *testing.T) { + result := pbinWitnessOf(t, api, tc.num) + require.NotEmpty(t, result.State, "a block that touches state proves it with nodes") + require.NotEmpty(t, result.Keys) + requirePBinWitnessVerifies(t, c, result, tc.num) + }) + } +} + +// A block carrying no transactions still pays the block reward, so it has a +// post-state root of its own to prove. +func TestPBinExecutionWitnessEmptyBlock(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + c := buildPBinWitnessChain(t) + api := pbinWitnessAPI(t, c.m) + + require.Empty(t, c.block(t, 7).Transactions(), "block 7 is the no-transaction block") + result := pbinWitnessOf(t, api, 7) + require.NotEmpty(t, result.State) + requirePBinWitnessVerifies(t, c, result, 7) +} + +// Each witness has to stand on its own: a block's proof may not lean on nodes +// its neighbour's witness happens to carry. +func TestPBinExecutionWitnessConsecutiveBlocks(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + c := buildPBinWitnessChain(t) + api := pbinWitnessAPI(t, c.m) + + first, second := pbinWitnessOf(t, api, 4), pbinWitnessOf(t, api, 5) + requirePBinWitnessVerifies(t, c, first, 4) + requirePBinWitnessVerifies(t, c, second, 5) + require.NotEqual(t, first.State, second.State, + "consecutive blocks over different pre-states cannot prove with the same node set") +} + +// A CREATE over a nonce-0, code-empty account with storage outside the account +// header. EIP-7610 makes that create fail, and the binary tree commits no +// per-account storage root, so a verifier can only reach the same verdict from a +// proof of the account's storage zone — which no leaf of the account's own +// header stem carries. +func TestPBinExecutionWitnessCreateCollisionOnZoneStorage(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + bankKey, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + require.NoError(t, err) + bankAddress := crypto.PubkeyToAddress(bankKey.PublicKey) + victim := types.CreateAddress(bankAddress, 0) + + chainConfig := new(chain.Config) + require.NoError(t, copier.CopyWithOption(chainConfig, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(&types.Genesis{ + Config: chainConfig, + Alloc: types.GenesisAlloc{ + bankAddress: {Balance: big.NewInt(1e18)}, + victim: { + Balance: big.NewInt(1), + Storage: map[common.Hash]common.Hash{pbinStatelessSlot(1 << 20): common.BigToHash(big.NewInt(9))}, + }, + }, + }), execmoduletester.WithKey(bankKey)) + + signer := types.LatestSignerForChainID(nil) + pack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 1, func(i int, b *blockgen.BlockGen) { + require.Zero(t, b.TxNonce(bankAddress), "the victim address is derived from nonce 0") + txn, err := types.SignTx(&types.LegacyTx{ + CommonTx: types.CommonTx{GasLimit: 200_000, Data: pbinDeployCode(pbinStoreRuntime)}, + GasPrice: *uint256.NewInt(1_000_000_000), + }, *signer, bankKey) + require.NoError(t, err) + b.AddTx(txn) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(pack)) + require.EqualValues(t, types.ReceiptStatusFailed, pack.Receipts[0][0].Status, + "the create must collide; a successful deploy proves nothing about the predicate") + + c := &pbinWitnessChain{m: m, pack: pack} + result := pbinWitnessOf(t, pbinWitnessAPI(t, m), 1) + requirePBinWitnessVerifies(t, c, result, 1) +} diff --git a/rpc/jsonrpc/pbin_witness_granularity_test.go b/rpc/jsonrpc/pbin_witness_granularity_test.go new file mode 100644 index 00000000000..bd04e175a1e --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_granularity_test.go @@ -0,0 +1,262 @@ +package jsonrpc + +// Byte-level accounting for the EEST test_witness_growth corpus, both arms. +// +// Every measured block calls a contract that executes the same 8 bytes; only the +// dead padding behind the STOP differs. Under hex the code ships as one blob +// beside a short account proof; under bin it is committed as 31-byte chunk leaves +// in the code zone, so the same call proves every chunk the contract occupies. +// +// Bin bytes are attributed by reading each leaf's own key: the zone byte and the +// sub-index say what the leaf is, so nothing here is inferred from position. + +import ( + "fmt" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jinzhu/copier" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// The chunk counts of tests/binary_tree/.../test_witness_growth.py, plus the +// code-zone group boundary at STEM_SUBTREE_WIDTH and a zero-padded tail. +var pbinGranCases = []struct { + name string + size int + chunks int + zeroPad bool +}{ + {"single_chunk", 31, 1, false}, + {"chunks_128", 31 * 128, 128, false}, + {"chunks_129", 31 * 129, 129, false}, + {"group_full", 31 * pbinCodeGroupChunks, 256, false}, + {"group_spill", 31 * (pbinCodeGroupChunks + 1), 257, false}, + {"max_code_size", 24576, 793, false}, + {"max_zero_padded", 24576, 793, true}, +} + +type pbinGranRow struct { + name string + // bin, by what the leaf's own key says it is + basicData, codeHash, codeChunk, storageLeaf int + branches, binNodes, binTotal int + // hex + hexState, hexCodes, hexNodes, hexTotal int + // block headers, measured in the arm they belong to + hexHeaders, binHeaders int + // the block that deploys the contract, against the block that reads it + deployBinNodes, deployBinTotal int + deployHexNodes, deployHexTotal int +} + +// pbinGranChain deploys one contract per case, then calls each in its own block. +// Deploys come first so every measured block is a pure read of pre-existing code. +func pbinGranChain(t *testing.T) (*pbinWitnessChain, []common.Address) { + t.Helper() + // Own genesis rather than fundedBankGenesis: a 24,576-byte code deposit is + // ~5M gas, past the default block limit that helper leaves on a Berlin config. + bankKey, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + require.NoError(t, err) + bankAddress := crypto.PubkeyToAddress(bankKey.PublicKey) + bankFunds, ok := new(big.Int).SetString("100000000000000000000", 10) + require.True(t, ok) + + chainConfig := new(chain.Config) + require.NoError(t, copier.CopyWithOption(chainConfig, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + m := execmoduletester.New(t, + execmoduletester.WithGenesisSpec(&types.Genesis{ + Config: chainConfig, + Alloc: types.GenesisAlloc{bankAddress: {Balance: bankFunds}}, + GasLimit: 60_000_000, + }), + execmoduletester.WithKey(bankKey)) + + signer := types.LatestSignerForChainID(nil) + gasPrice := uint256.NewInt(1_000_000_000) + addrs := make([]common.Address, len(pbinGranCases)) + + sign := func(txn *types.LegacyTx) types.Transaction { + t.Helper() + txn.GasPrice = *gasPrice + signed, err := types.SignTx(txn, *signer, bankKey) + require.NoError(t, err) + return signed + } + + n := len(pbinGranCases) + pack, err2 := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 2*n, func(i int, b *blockgen.BlockGen) { + nonce := b.TxNonce(bankAddress) + if i < n { // deploy + // Padding is INVALID, not zero: a chunk of 31 zero bytes is stored as no + // leaf at all, so zero padding would measure the collapse rather than the + // cost of code. The zeroPad case covers that collapse deliberately. + runtime := make([]byte, pbinGranCases[i].size) + for j := range runtime { + runtime[j] = 0xfe + } + copy(runtime, pbinStoreRuntime) + if pbinGranCases[i].zeroPad { + clear(runtime[len(pbinStoreRuntime):]) + } + addrs[i] = types.CreateAddress(bankAddress, nonce) + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, GasLimit: 12_000_000, Data: pbinDeployCode(runtime), + }})) + return + } + c := i - n // call + b.AddTx(sign(&types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: nonce, To: &addrs[c], GasLimit: 200_000, + Data: pbinStoreCalldata(common.HexToHash("0x01"), uint64(c+1)), + }})) + }) + require.NoError(t, err2) + require.NoError(t, m.InsertChain(pack)) + return &pbinWitnessChain{m: m, pack: pack}, addrs +} + +func TestPBinWitnessGranularity(t *testing.T) { + withCommitmentHistory(t) + n := len(pbinGranCases) + rows := make([]pbinGranRow, n) + for i := range rows { + rows[i].name = pbinGranCases[i].name + } + + // The relations below are bin against hex, so they hold only once both arms + // have measured; a run filtered to one subtest compares against zeroes. + var hexRan, binRan bool + + // hex arm + t.Run("hex", func(t *testing.T) { + c, _ := pbinGranChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i := range pbinGranCases { + w := pbinWitnessOf(t, api, uint64(n+i+1)) + rows[i].hexState = sumBytes(w.State) + rows[i].hexNodes = len(w.State) + rows[i].hexCodes = sumBytes(w.Codes) + rows[i].hexHeaders = sumBytes(w.Headers) + rows[i].hexTotal = rows[i].hexState + rows[i].hexCodes + rows[i].hexHeaders + + d := pbinWitnessOf(t, api, uint64(i+1)) + rows[i].deployHexNodes = len(d.State) + rows[i].deployHexTotal = sumBytes(d.State) + sumBytes(d.Codes) + sumBytes(d.Headers) + } + hexRan = true + }) + + // bin arm + t.Run("bin", func(t *testing.T) { + withBinCommitmentDatadir(t) + c, _ := pbinGranChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i := range pbinGranCases { + w := pbinWitnessOf(t, api, uint64(n+i+1)) + r := &rows[i] + for _, node := range w.State { + r.binNodes++ + r.binTotal += len(node) + key := pbinLeafKeyOf(node) + if key == nil { + r.branches += len(node) + continue + } + switch sub := key[len(key)-1]; { + case key[0] == 0x01: + r.codeChunk += len(node) + case key[0] == 0xFF: + r.storageLeaf += len(node) + case sub == 0: + r.basicData += len(node) + case sub == 1: + r.codeHash += len(node) + case sub == pbinDelegationSubIndex: + r.codeHash += len(node) + default: + // The header window is the only other allocated part of the + // account zone; everything between is reserved. + require.True(t, sub >= 64 && sub < 128, + "%s: account-zone leaf at reserved sub-index %d", r.name, sub) + r.storageLeaf += len(node) + } + } + r.binHeaders = sumBytes(w.Headers) + r.binTotal += r.binHeaders + + d := pbinWitnessOf(t, api, uint64(i+1)) + r.deployBinNodes = len(d.State) + r.deployBinTotal = sumBytes(d.State) + sumBytes(d.Headers) + } + binRan = true + }) + + if !hexRan || !binRan { + t.Log("bin-vs-hex relations need both arms; run the test without a subtest filter") + return + } + + // Direction stated up front: chunk leaves and the branches binding them + // outweigh hex's flat code blob at every size, the gap widens with chunk + // count, and a zero-padded tail collapses to elided leaves that undercut + // the blob. + ratio := map[int]float64{} + for i, gc := range pbinGranCases { + r := &rows[i] + if gc.zeroPad { + require.Less(t, r.binTotal, r.hexTotal, "%s: elided zero chunks must undercut hex", gc.name) + continue + } + require.Greater(t, r.binTotal, r.hexTotal, "%s: chunked code must outweigh hex", gc.name) + ratio[gc.chunks] = float64(r.binTotal) / float64(r.hexTotal) + } + for _, step := range [][2]int{{1, 128}, {128, 256}, {256, 793}} { + require.Greater(t, ratio[step[1]], ratio[step[0]], + "bin/hex must grow from %d to %d chunks", step[0], step[1]) + } + + t.Log("witness bytes for a call executing 8 bytes, by contract size\n" + pbinGranTable(rows)) +} + +func pbinGranTable(rows []pbinGranRow) string { + s := fmt.Sprintf("%-16s %7s %5s %8s %9s %8s | %6s %7s %8s %8s | %7s\n", + "case", "code B", "chunks", "hex tot", "bin tot", "bin/hex", "hexNod", "hexCode", "binNod", "chunkB", "noChunk") + for i := range rows { + r := &rows[i] + noChunk := r.binTotal - r.codeChunk + s += fmt.Sprintf("%-16s %7d %5d %8d %9d %7.2fx | %6d %7d %8d %8d | %7d\n", + r.name, pbinGranCases[i].size, pbinGranCases[i].chunks, + r.hexTotal, r.binTotal, float64(r.binTotal)/float64(r.hexTotal), + r.hexNodes, r.hexCodes, r.binNodes, r.codeChunk, noChunk) + } + s += "\ndeploying the contract against reading it back:\n" + s += fmt.Sprintf("%-16s %8s %8s | %8s %8s | %8s %8s\n", + "case", "depBinN", "depBinB", "readBinN", "readBinB", "depHexB", "readHexB") + for i := range rows { + r := &rows[i] + s += fmt.Sprintf("%-16s %8d %8d | %8d %8d | %8d %8d\n", + r.name, r.deployBinNodes, r.deployBinTotal, r.binNodes, r.binTotal, + r.deployHexTotal, r.hexTotal) + } + s += "\nbin state bytes by what the leaf's key says it is:\n" + s += fmt.Sprintf("%-16s %10s %9s %12s %8s %10s\n", + "case", "BASIC_DATA", "CODE_HASH", "code chunks", "storage", "branches") + for i := range rows { + r := &rows[i] + s += fmt.Sprintf("%-16s %10d %9d %12d %8d %10d\n", + r.name, r.basicData, r.codeHash, r.codeChunk, r.storageLeaf, r.branches) + } + return s +} diff --git a/rpc/jsonrpc/pbin_witness_phases_test.go b/rpc/jsonrpc/pbin_witness_phases_test.go new file mode 100644 index 00000000000..f06d3e093f0 --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_phases_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/rpcdaemon/rpcdaemontest" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment/trie" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// The collapse phase is driven with every dependency nil: if the bin skip ever goes +// away, the phase dereferences one of them instead of returning cleanly. +func TestPBinWitnessSkipsCollapseDetection(t *testing.T) { + t.Parallel() + + var siblingPaths [][]byte + var err error + require.NotPanics(t, func() { + siblingPaths, err = detectCollapseSiblings(t.Context(), nil, nil, nil, nil, + 0, 0, 0, 0, common.Hash{}, nil, witnessModeLegacy, true /* binTrie */) + }, "the binary trie must not enter collapse detection: SetCollapseTracer panics under bin") + require.NoError(t, err) + require.Empty(t, siblingPaths, "the binary trie never collapses a branch") +} + +// A collapse sibling reaching the bin trie phase would be touched as a hashed key with +// an empty plain key, which the bin update stream cannot resolve. The guard runs before +// any dependency is used, so nil deps are enough to reach it. +func TestPBinWitnessTrieRefusesCollapseSiblings(t *testing.T) { + t.Parallel() + + nodes, err := buildWitnessTrie(t.Context(), nil, nil, nil, nil, 0, common.Hash{}, + [][]byte{{0x01, 0x02}}, nil, true /* produceExclusionProofs */, true /* binTrie */) + require.Error(t, err) + require.Nil(t, nodes) + require.Contains(t, err.Error(), "collapse sibling") +} + +func TestPBinWitnessModeRejectsExplicitCanonical(t *testing.T) { + t.Parallel() + + str := func(s string) *string { return &s } + + for _, tc := range []struct { + name string + param *string + }{ + {"absent", nil}, + {"empty", str("")}, + {"legacy", str("legacy")}, + } { + t.Run(tc.name+" mode resolves to legacy under bin", func(t *testing.T) { + got, err := resolveWitnessMode(tc.param, true /* binTrie */) + require.NoError(t, err, "rejecting the legacy default would reject every bin request") + require.Equal(t, witnessModeLegacy, got) + }) + } + + got, err := resolveWitnessMode(str("canonical"), true /* binTrie */) + require.ErrorIs(t, err, errWitnessCanonicalHexOnly) + require.Equal(t, witnessModeLegacy, got) + + got, err = resolveWitnessMode(str("canonical"), false /* binTrie */) + require.NoError(t, err, "hex keeps both modes") + require.Equal(t, witnessModeCanonical, got) +} + +// TestPBinExecutionWitnessRejectsCanonicalRequest pins the wiring: the mode gate reads +// the datadir's variant, so an explicit canonical request under bin is refused, while a +// default-mode request gets past the gate and fails for its own reasons. +func TestPBinExecutionWitnessRejectsCanonicalRequest(t *testing.T) { + // No t.Parallel: mutates process-global statecfg flags. + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + api := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) + + orig := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = orig }) + statecfg.ExperimentalBinCommitment = true + require.True(t, binCommitmentTrie()) + + canonical := "canonical" + latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) + _, err := api.ExecutionWitness(t.Context(), latest, &canonical) + require.ErrorIs(t, err, errWitnessCanonicalHexOnly) + + _, err = api.ExecutionWitness(t.Context(), latest, nil) + require.NotErrorIs(t, err, errWitnessCanonicalHexOnly, "the legacy default must get past the mode gate") +} + +// The 0x80 empty storage-trie node is an MPT artifact with no binary-trie counterpart. +func TestPBinWitnessOmitsEmptyStorageNode(t *testing.T) { + t.Parallel() + + accountLeaf := hexutil.Bytes(append([]byte{0xf8, 0x44}, trie.EmptyRoot[:]...)) + nodes := []hexutil.Bytes{accountLeaf} + + hexLegacy := appendLegacyEmptyStorageNode(nodes, witnessModeLegacy, false /* binTrie */) + require.Len(t, hexLegacy, 2) + require.Equal(t, hexutil.Bytes{0x80}, hexLegacy[1]) + + require.Equal(t, nodes, appendLegacyEmptyStorageNode(nodes, witnessModeLegacy, true /* binTrie */), + "the binary trie has no empty storage-trie node") + require.Equal(t, nodes, appendLegacyEmptyStorageNode(nodes, witnessModeCanonical, false /* binTrie */)) +} + +// errWitnessCanonicalHexOnly and errWitnessCanonicalUnavailable both refuse a canonical +// request but for unrelated reasons; a caller distinguishing them must not be able to +// match one with the other. +func TestPBinWitnessCanonicalErrorsAreDistinct(t *testing.T) { + t.Parallel() + + require.False(t, errors.Is(errWitnessCanonicalHexOnly, errWitnessCanonicalUnavailable)) + require.False(t, errors.Is(errWitnessCanonicalUnavailable, errWitnessCanonicalHexOnly)) +} diff --git a/rpc/jsonrpc/pbin_witness_reachable_test.go b/rpc/jsonrpc/pbin_witness_reachable_test.go new file mode 100644 index 00000000000..e1bf34c73e7 --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_reachable_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/rpcdaemon/rpcdaemontest" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/node/ethconfig" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// The commitment variant and its hash are datadir properties resolved process-wide, +// so a test using this may never run in parallel. +func withBinCommitmentDatadir(t *testing.T) { + t.Helper() + + origBin, origHash, origSuite := statecfg.ExperimentalBinCommitment, statecfg.BinCommitmentHash, commitment.PBinHashSuiteName() + origParallel, origStreaming := statecfg.ExperimentalParallelCommitment, statecfg.ExperimentalStreamingCommitment + t.Cleanup(func() { + statecfg.ExperimentalBinCommitment = origBin + statecfg.BinCommitmentHash = origHash + require.NoError(t, commitment.SetPBinHashSuite(origSuite)) + statecfg.ExperimentalParallelCommitment = origParallel + statecfg.ExperimentalStreamingCommitment = origStreaming + }) + statecfg.ExperimentalBinCommitment = true + statecfg.BinCommitmentHash = commitment.PBinHashBlake3 + require.NoError(t, commitment.SetPBinHashSuite(commitment.PBinHashBlake3)) + // erigondb.toml resolution refuses bin combined with either: the bin trie is + // sequential-only, regardless of a process-wide parallel/streaming default. + statecfg.ExperimentalParallelCommitment = false + statecfg.ExperimentalStreamingCommitment = false +} + +func withCommitmentHistory(t *testing.T) { + t.Helper() + + previousSchema := statecfg.Schema + t.Cleanup(func() { statecfg.Schema = previousSchema }) + statecfg.EnableHistoricalCommitment() +} + +func enableCommitmentHistoryFlag(t *testing.T, db kv.TemporalRwDB) { + t.Helper() + + require.NoError(t, db.Update(t.Context(), func(tx kv.RwTx) error { + return rawdb.WriteDBCommitmentHistoryEnabled(tx, true) + })) +} + +// TestPBinExecutionWitnessReachable confirms a bin datadir reaches the witness pipeline +// instead of ErrBinCommitmentUnsupported. Under bin the stateless gate is not skippable, so +// a returned witness is one that re-executed the block to its post-state root. +func TestPBinExecutionWitnessReachable(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + withBinCommitmentDatadir(t) + + m, _, _, _ := chainWithDeployedContract(t) + enableCommitmentHistoryFlag(t, m.DB) + require.True(t, binCommitmentTrie(), "the chain above is committed with the binary trie") + + api := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) + + // Block 2 calls the contract deployed by block 1, so its witness covers an account + // read, a storage write and a code read. + bn := rpc.BlockNumber(2) + result, err := api.ExecutionWitness(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &bn}, nil) + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result.State, "a block that touches state proves it with nodes") + require.NotEmpty(t, result.Keys) +} + +// The witness capture serves the sequential hex trie and the bin trie; the parallel +// trie it cannot serve must still be demoted rather than reaching the capture. +func TestWitnessPathDemotesParallelTrie(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + enableCommitmentHistoryFlag(t, m.DB) + + api := NewPrivateDebugAPI(newBaseApiForTest(m), m.DB, nil, &rpccfg.DebugApiConfig{}) + bn := rpc.BlockNumber(3) + block := rpc.BlockNumberOrHash{BlockNumber: &bn} + + sequential, err := api.ExecutionWitness(t.Context(), block, nil) + require.NoError(t, err) + + orig := statecfg.ExperimentalParallelCommitment + t.Cleanup(func() { statecfg.ExperimentalParallelCommitment = orig }) + statecfg.ExperimentalParallelCommitment = true + + demoted, err := api.ExecutionWitness(t.Context(), block, nil) + require.NoError(t, err, "the parallel trie must be demoted, not handed to the witness capture") + require.Equal(t, sequential.State, demoted.State) +} + +// eth_getWitness recomputes with the hex trie and has no bin implementation, so it +// must keep refusing a bin datadir rather than reading bit-path records as hex ones. +func TestPBinGetWitnessRefusesBin(t *testing.T) { + // No t.Parallel: mutates process-global commitment flags. + withCommitmentHistory(t) + + m, _, _ := rpcdaemontest.CreateTestExecModule(t) + enableCommitmentHistoryFlag(t, m.DB) + + cfg := &rpccfg.EthApiConfig{ + GasCap: 5000000, + FeeCap: ethconfig.Defaults.RPCTxFeeCap, + ReturnDataLimit: 100_000, + MaxGetProofRewindBlockCount: 1, + SubscribeLogsChannelSize: 128, + RpcTxSyncDefaultTimeout: 20 * time.Second, + RpcTxSyncMaxTimeout: 1 * time.Minute, + } + api := NewEthAPI(newBaseApiForTest(m), m.DB, nil, nil, nil, cfg, log.New()) + + // The chain above is built on the hex trie; only the witness call runs under bin. + origBin := statecfg.ExperimentalBinCommitment + t.Cleanup(func() { statecfg.ExperimentalBinCommitment = origBin }) + statecfg.ExperimentalBinCommitment = true + + bn := rpc.BlockNumber(3) + _, err := api.GetWitness(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &bn}) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) +} + +// debug_executionWitness is the only caller that stopped declaring itself hex-only. +// The refusal of the rest is a source property — each has to keep passing the option +// whose bin behaviour execctx.TestPBinHexOnlyCommitmentRefusesBin pins — so it is +// checked where it lives rather than by re-deriving every caller's preconditions. +func TestPBinHexOnlyCallersStillRefuse(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + for _, rel := range []string{ + "rpc/jsonrpc/eth_call.go", // eth_getProof, eth_getWitness + "rpc/jsonrpc/eth_simulation.go", // eth_simulateV1 + "rpc/jsonrpc/receipts/receipts_generator.go", + "rpc/rpchelper/commitment.go", + "db/integrity/commitment_integrity.go", + } { + src, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + require.NoError(t, err) + for i, line := range strings.Split(string(src), "\n") { + if !strings.Contains(line, "execctx.NewSharedDomains(") { + continue + } + require.Contains(t, line, "execctx.WithHexCommitmentOnly()", + "%s:%d recomputes with the hex trie and must keep refusing bin", rel, i+1) + } + } + + src, err := os.ReadFile(filepath.Join(root, filepath.FromSlash("rpc/jsonrpc/debug_execution_witness.go"))) + require.NoError(t, err) + require.NotContains(t, string(src), "execctx.WithHexCommitmentOnly()", + "the witness path serves bin through its own collector") +} diff --git a/rpc/jsonrpc/pbin_witness_size_test.go b/rpc/jsonrpc/pbin_witness_size_test.go new file mode 100644 index 00000000000..23d93fcc25d --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_size_test.go @@ -0,0 +1,198 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +// hexWitnessBaselinePath pins the hex arm's measured sizes. Regenerate with +// ERIGON_UPDATE_HEX_WITNESS_BASELINE=true when hex witness output changes on purpose. +var hexWitnessBaselinePath = filepath.Join("testdata", "hex_witness_baseline.json") + +// witnessSizes is one block's witness payload split into the parts that scale +// differently. The split matters because under the binary trie a block's code is +// committed as chunk leaves inside State, so Codes repeats bytes State already +// carries; see totalBytes. +type witnessSizes struct { + Block uint64 `json:"block"` + Shape string `json:"shape"` + Nodes int `json:"nodes"` + StateBytes int `json:"stateBytes"` + Codes int `json:"codes"` + CodeBytes int `json:"codeBytes"` + Headers int `json:"headers"` + HeaderBytes int `json:"headerBytes"` +} + +// totalBytes is what a stateless verifier has to be handed. Under bin the code +// blobs are redundant — the reader reassembles code from the chunk leaves already +// counted in StateBytes — so adding Codes there would count code twice and make +// the two arms incomparable. +func (s witnessSizes) totalBytes(binTrie bool) int { + if binTrie { + return s.StateBytes + s.HeaderBytes + } + return s.StateBytes + s.CodeBytes + s.HeaderBytes +} + +func sumBytes(blobs []hexutil.Bytes) int { + total := 0 + for _, blob := range blobs { + total += len(blob) + } + return total +} + +// pbinWitnessCorpus names what each block of buildPBinWitnessChain exercises, so +// the measured table reads as a size per witness shape rather than per block number. +var pbinWitnessCorpus = []struct { + num uint64 + shape string +}{ + {1, "plain transfer"}, + {2, "deploy within one code-zone group"}, + {3, "deploy crossing a group boundary"}, + {4, "storage write"}, + {5, "SSTORE to zero"}, + {6, "code read across a group boundary"}, + {7, "no transactions"}, +} + +// measureWitnessSizes builds the corpus chain under one commitment variant and +// measures every block's witness. Both arms run with the stateless gate on, so a +// witness that got measured is a witness that re-executed its block to the header's +// post-state root. +func measureWitnessSizes(t *testing.T, binTrie bool) []witnessSizes { + t.Helper() + + t.Setenv("ERIGON_WITNESS_NO_VERIFY", "false") + if binTrie { + withBinCommitmentDatadir(t) + } + require.Equal(t, binTrie, binCommitmentTrie()) + require.False(t, witnessVerifySkipped(binTrie), "a measured witness must be a verified one") + + c := buildPBinWitnessChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + + sizes := make([]witnessSizes, 0, len(pbinWitnessCorpus)) + for _, block := range pbinWitnessCorpus { + result := pbinWitnessOf(t, api, block.num) + require.NotEmpty(t, result.State, "block %d touches state", block.num) + sizes = append(sizes, witnessSizes{ + Block: block.num, + Shape: block.shape, + Nodes: len(result.State), + StateBytes: sumBytes(result.State), + Codes: len(result.Codes), + CodeBytes: sumBytes(result.Codes), + Headers: len(result.Headers), + HeaderBytes: sumBytes(result.Headers), + }) + } + return sizes +} + +// requireHexBaseline holds the hex arm to its committed numbers. The bin work must +// leave hex witness output alone, and a golden file makes that checkable here rather +// than by building the same corpus on another branch. +func requireHexBaseline(t *testing.T, sizes []witnessSizes) { + t.Helper() + + encoded, err := json.MarshalIndent(sizes, "", " ") + require.NoError(t, err) + encoded = append(encoded, '\n') + + if dbg.EnvBool("ERIGON_UPDATE_HEX_WITNESS_BASELINE", false) { + require.NoError(t, os.WriteFile(hexWitnessBaselinePath, encoded, 0o644)) + t.Fatalf("rewrote %s; re-run without ERIGON_UPDATE_HEX_WITNESS_BASELINE", hexWitnessBaselinePath) + } + + baseline, err := os.ReadFile(hexWitnessBaselinePath) + require.NoError(t, err) + require.JSONEq(t, string(baseline), string(encoded), + "hex witness sizes moved: the bin witness path must leave the hex one byte-identical") +} + +// witnessSizeTable renders the measured arms as markdown, for the plan's table. +func witnessSizeTable(hexArm, binArm []witnessSizes) string { + var b strings.Builder + b.WriteString("| block | shape | hex nodes | hex state B | bin nodes | bin state B | bin/hex state |\n") + b.WriteString("|---|---|---:|---:|---:|---:|---:|\n") + + var hexTotal, binTotal int + for i, h := range hexArm { + n := binArm[i] + hexTotal += h.totalBytes(false) + binTotal += n.totalBytes(true) + fmt.Fprintf(&b, "| %d | %s | %d | %d | %d | %d | %.2fx |\n", + h.Block, h.Shape, h.Nodes, h.StateBytes, n.Nodes, n.StateBytes, + float64(n.StateBytes)/float64(h.StateBytes)) + } + + b.WriteString("\n| block | hex codes | hex code B | bin codes | bin code B | hex headers B | bin headers B |\n") + b.WriteString("|---|---:|---:|---:|---:|---:|---:|\n") + for i, h := range hexArm { + n := binArm[i] + fmt.Fprintf(&b, "| %d | %d | %d | %d | %d | %d | %d |\n", + h.Block, h.Codes, h.CodeBytes, n.Codes, n.CodeBytes, h.HeaderBytes, n.HeaderBytes) + } + + fmt.Fprintf(&b, "\ncorpus total handed to a verifier: hex %d B, bin %d B (%.2fx)\n", + hexTotal, binTotal, float64(binTotal)/float64(hexTotal)) + return b.String() +} + +// TestWitnessSizeBinVsHex builds one block sequence twice — same genesis, same +// transactions, different commitment trie — and measures both witnesses, so binary +// witness sizes come from real blocks instead of estimates. +func TestWitnessSizeBinVsHex(t *testing.T) { + // No t.Parallel, and the arms run in sequence: the commitment variant and its + // hash suite are process-global, and each arm restores what it set. + withCommitmentHistory(t) + + // Each arm checks itself, so either runs alone; only the joint table needs both. + var hexArm, binArm []witnessSizes + t.Run("hex", func(t *testing.T) { + hexArm = measureWitnessSizes(t, false) + require.Len(t, hexArm, len(pbinWitnessCorpus)) + requireHexBaseline(t, hexArm) + }) + t.Run("bin", func(t *testing.T) { + binArm = measureWitnessSizes(t, true) + require.Len(t, binArm, len(pbinWitnessCorpus)) + }) + + if len(hexArm) != len(pbinWitnessCorpus) || len(binArm) != len(pbinWitnessCorpus) { + t.Log("the bin-vs-hex table needs both arms; run the test without a subtest filter") + return + } + t.Log("witness sizes, bin vs hex:\n" + witnessSizeTable(hexArm, binArm)) +} diff --git a/rpc/jsonrpc/pbin_witness_stateless.go b/rpc/jsonrpc/pbin_witness_stateless.go new file mode 100644 index 00000000000..19f419d925b --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_stateless.go @@ -0,0 +1,390 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "context" + "errors" + "fmt" + + "github.com/holiman/uint256" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/empty" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/protocol/rules" + "github.com/erigontech/erigon/execution/state" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" +) + +// Re-executing a block against a binary witness alone. This is the bin analogue +// of witnessStateless: same StateReader/StateWriter seams, resolving leaves by +// tree key instead of by MPT path, and finalizing through PBinPatriciaHashed +// rather than an in-memory MPT. +// +// Strict resolution is the only mode. Hex makes it a WITNESS_STRICT_VERIFY +// opt-in because an unresolved MPT node is not always a defect; under bin an +// unresolved hash is unambiguous, so a missing node is always an error and never +// an empty read. +// +// Code has one owner: the witness's own leaves — chunks reassembled and checked +// against the CODE_HASH leaf, or the delegation indicator read from its header +// leaf (commitment.PBinWitnessState.Code). result.Codes is not read. The leaves +// are committed by the root and the fold re-chunks every account it touches, so +// the pruned witness carries them wherever the post-state pass needs code; a +// blob list is keyed by code reads, a strictly narrower set. Code a block +// deploys has no pre-state leaves and arrives through UpdateAccountCode, as it +// does under hex. + +// pbinExecBlockStatelessly re-executes the block against the binary witness alone +// and returns the post-state root it reaches. It is the bin arm of the gate +// debug_executionWitness applies before returning a witness; the replay itself is +// shared with hex. parentRoot roots the decode: the node set is not self-rooting. +func pbinExecBlockStatelessly( + ctx context.Context, + result *ExecutionWitnessResult, + block *types.Block, + parentRoot common.Hash, + chainConfig *chain.Config, + engine rules.Engine, +) (postStateRoot common.Hash, stateless *pbinWitnessStateless, err error) { + // Genesis has no transactions but does have pre-allocated accounts, which no + // witness covers. + if block.NumberU64() == 0 { + return block.Root(), nil, nil + } + if len(result.State) == 0 { + return common.Hash{}, nil, errors.New("empty State field in witness") + } + + stateless, err = newPBinWitnessStateless(result, parentRoot) + if err != nil { + return common.Hash{}, nil, err + } + if err := replayBlockOverWitness(result, block, chainConfig, engine, stateless); err != nil { + return common.Hash{}, stateless, err + } + + root, err := stateless.Finalize(ctx) + if err != nil { + return common.Hash{}, stateless, fmt.Errorf("[statelessExec] pbin post-state root failed: %w", err) + } + return root, stateless, nil +} + +type pbinWitnessStateless struct { + state *commitment.PBinWitnessState + + codeUpdates map[common.Address][]byte + accountUpdates map[common.Address]*accounts.Account + storageWrites map[common.Address]map[common.Hash]uint256.Int + deleted map[common.Address]struct{} + + // preimages the witness supplied during re-exec; keys[] must cover these + usedTrieAddrs map[common.Address]struct{} + usedTrieSlots map[common.Hash]struct{} + + trace bool +} + +var ( + _ state.StateReader = (*pbinWitnessStateless)(nil) + _ state.StateWriter = (*pbinWitnessStateless)(nil) +) + +func newPBinWitnessStateless(result *ExecutionWitnessResult, parentRoot common.Hash) (*pbinWitnessStateless, error) { + nodes := make([][]byte, len(result.State)) + for i, node := range result.State { + nodes[i] = node + } + witnessState, err := commitment.PBinNewWitnessState(nodes, parentRoot[:]) + if err != nil { + return nil, fmt.Errorf("failed to decode binary witness: %w", err) + } + return &pbinWitnessStateless{ + state: witnessState, + codeUpdates: make(map[common.Address][]byte), + accountUpdates: make(map[common.Address]*accounts.Account), + storageWrites: make(map[common.Address]map[common.Hash]uint256.Int), + deleted: make(map[common.Address]struct{}), + usedTrieAddrs: make(map[common.Address]struct{}), + usedTrieSlots: make(map[common.Hash]struct{}), + }, nil +} + +func (s *pbinWitnessStateless) SetTrace(trace bool, tracePrefix string) { s.trace = trace } +func (s *pbinWitnessStateless) Trace() bool { return s.trace } +func (s *pbinWitnessStateless) TracePrefix() string { return "" } + +func (s *pbinWitnessStateless) ReadAccountDataForDebug(address accounts.Address) (*accounts.Account, error) { + return s.ReadAccountData(address) +} + +func (s *pbinWitnessStateless) ReadAccountData(address accounts.Address) (*accounts.Account, error) { + addr := address.Value() + if acc, ok := s.accountUpdates[addr]; ok { + return acc, nil + } + if _, ok := s.deleted[addr]; ok { + return nil, nil + } + return s.preStateAccount(addr) +} + +func (s *pbinWitnessStateless) preStateAccount(addr common.Address) (*accounts.Account, error) { + witnessAcc, ok, err := s.state.Account(addr[:]) + if err != nil { + return nil, err + } + if !ok { + return nil, nil + } + s.usedTrieAddrs[addr] = struct{}{} + // The binary tree commits no per-account storage root, so Root stays empty. + acc := &accounts.Account{ + Nonce: witnessAcc.Nonce, + Balance: witnessAcc.Balance, + Root: empty.RootHash, + CodeHash: accounts.InternCodeHash(witnessAcc.CodeHash), + } + return acc, nil +} + +func (s *pbinWitnessStateless) ReadAccountStorage(address accounts.Address, key accounts.StorageKey) (uint256.Int, bool, error) { + addr, slot := address.Value(), key.Value() + if m, ok := s.storageWrites[addr]; ok { + if v, ok := m[slot]; ok { + return v, true, nil + } + } + if _, ok := s.deleted[addr]; ok { + return uint256.Int{}, false, nil + } + value, ok, err := s.state.Storage(addr[:], slot[:]) + if err != nil || !ok { + return uint256.Int{}, false, err + } + s.usedTrieSlots[slot] = struct{}{} + var v uint256.Int + v.SetBytes(value[:]) + return v, !v.IsZero(), nil +} + +func (s *pbinWitnessStateless) ReadAccountCode(address accounts.Address) ([]byte, error) { + addr := address.Value() + if code, ok := s.codeUpdates[addr]; ok { + return code, nil + } + if _, ok := s.deleted[addr]; ok { + return nil, nil + } + code, _, err := s.state.Code(addr[:]) + return code, err +} + +func (s *pbinWitnessStateless) ReadAccountCodeSize(address accounts.Address) (int, error) { + code, err := s.ReadAccountCode(address) + if err != nil { + return 0, err + } + return len(code), nil +} + +func (s *pbinWitnessStateless) ReadAccountIncarnation(address accounts.Address) (uint64, error) { + return 0, nil +} + +// HasStorage answers EIP-7610's CREATE-collision predicate. The binary tree +// commits no per-account storage root, so the witness's own leaves are the +// source: the header slots resolve off the proof path the account's leaves sit +// on, and the storage zone off the probe the builder touches for it (see +// accessedState.pbinStorageProbes). +func (s *pbinWitnessStateless) HasStorage(address accounts.Address) (bool, error) { + addr := address.Value() + if _, ok := s.deleted[addr]; ok { + return false, nil + } + for _, v := range s.storageWrites[addr] { + if !v.IsZero() { + return true, nil + } + } + return s.state.HasStorage(addr[:]), nil +} + +func (s *pbinWitnessStateless) UpdateAccountData(address accounts.Address, original, account *accounts.Account) error { + addr := address.Value() + if account == nil { + s.accountUpdates[addr] = nil + return nil + } + accCopy := new(accounts.Account) + accCopy.Copy(account) + s.accountUpdates[addr] = accCopy + return nil +} + +// DeleteAccount records the removal. The pre-state read is what makes it strict: +// an account whose leaves the witness cannot resolve errors here rather than +// being dropped on a guess. +func (s *pbinWitnessStateless) DeleteAccount(address accounts.Address, original *accounts.Account) error { + addr := address.Value() + if _, err := s.preStateAccount(addr); err != nil { + return err + } + delete(s.accountUpdates, addr) + delete(s.storageWrites, addr) + delete(s.codeUpdates, addr) + s.deleted[addr] = struct{}{} + return nil +} + +func (s *pbinWitnessStateless) UpdateAccountCode(address accounts.Address, incarnation uint64, codeHash accounts.CodeHash, code []byte) error { + addr := address.Value() + s.codeUpdates[addr] = code + if acc, ok := s.accountUpdates[addr]; ok && acc != nil { + acc.CodeHash = codeHash + } + return nil +} + +func (s *pbinWitnessStateless) WriteAccountStorage(address accounts.Address, incarnation uint64, key accounts.StorageKey, original, value uint256.Int) error { + addr, slot := address.Value(), key.Value() + m, ok := s.storageWrites[addr] + if !ok { + m = make(map[common.Hash]uint256.Int) + s.storageWrites[addr] = m + } + m[slot] = value + return nil +} + +// CreateContract un-deletes the address: a create over an account dropped +// earlier in the block puts its leaves back. Pre-state storage under it is the +// one case this cannot express — the chain drops the whole storage prefix here, +// and no plain-key update reaches that subtree without also dropping the header +// the create rewrites. EIP-7610 keeps a create off such an account, so the case +// is refused rather than answered with a root that keeps the leaves. +func (s *pbinWitnessStateless) CreateContract(address accounts.Address) error { + addr := address.Value() + if s.state.HasStorage(addr[:]) { + return fmt.Errorf("create over account %x whose pre-state storage the witness proves", addr) + } + delete(s.deleted, addr) + return nil +} + +func (s *pbinWitnessStateless) Finalize(ctx context.Context) (common.Hash, error) { + plainKeys, updates, err := s.pendingUpdates() + if err != nil { + return common.Hash{}, err + } + root, err := s.state.Root(ctx, plainKeys, updates) + if err != nil { + return common.Hash{}, err + } + return common.BytesToHash(root), nil +} + +func (s *pbinWitnessStateless) pendingUpdates() (plainKeys [][]byte, updates []commitment.Update, err error) { + // A removal is one update on the address: the engine drops the account's + // header stem and its storage subtree, neither of which the writes enumerate. + // Removals go first so that a write the block made after one merges over it, + // as the same pair merges when the domain layer collects a block's updates: + // DeleteAccount clears the maps below, so anything left in them is later. + for addr := range s.deleted { + plainKeys = append(plainKeys, addr[:]) + updates = append(updates, commitment.Update{Flags: commitment.DeleteUpdate}) + } + for addr, acc := range s.accountUpdates { + if acc == nil { + continue + } + update, err := s.accountUpdate(addr, acc) + if err != nil { + return nil, nil, err + } + plainKeys = append(plainKeys, addr[:]) + updates = append(updates, update) + } + for addr, written := range s.storageWrites { + for slot, value := range written { + update, keep, err := s.storageUpdate(addr, slot, value) + if err != nil { + return nil, nil, err + } + if !keep { + continue + } + key := make([]byte, 0, len(addr)+len(slot)) + key = append(append(key, addr[:]...), slot[:]...) + plainKeys = append(plainKeys, key) + updates = append(updates, update) + } + } + return plainKeys, updates, nil +} + +// accountUpdate carries the code size the BASIC_DATA leaf packs, which the +// account itself does not hold: code deployed in-block comes from the write, and +// unchanged code from the witness. +func (s *pbinWitnessStateless) accountUpdate(addr common.Address, acc *accounts.Account) (commitment.Update, error) { + update := commitment.Update{ + Flags: commitment.NonceUpdate | commitment.BalanceUpdate | commitment.CodeUpdate, + Nonce: acc.Nonce, + Balance: acc.Balance, + CodeHash: acc.CodeHash.Value(), + } + if update.CodeHash == empty.CodeHash { + return update, nil + } + if code, ok := s.codeUpdates[addr]; ok { + update.CodeSize = uint64(len(code)) + s.state.SetCode(addr[:], code) + return update, nil + } + witnessAcc, ok, err := s.state.Account(addr[:]) + if err != nil { + return update, err + } + if !ok || witnessAcc.CodeHash != update.CodeHash { + return update, fmt.Errorf("witness holds no code for account %x with code hash %x", addr, update.CodeHash) + } + update.CodeSize = witnessAcc.CodeSize + return update, nil +} + +// storageUpdate writes a zeroed slot the witness holds, which the fold reads as +// a removal of its leaf. A slot with no leaf to begin with is dropped instead: +// there is nothing to remove, and the walk would prove a key the block never +// reached. +func (s *pbinWitnessStateless) storageUpdate(addr common.Address, slot common.Hash, value uint256.Int) (commitment.Update, bool, error) { + update := commitment.Update{Flags: commitment.StorageUpdate} + if value.IsZero() { + _, ok, err := s.state.Storage(addr[:], slot[:]) + if err != nil || !ok { + return update, false, err + } + return update, true, nil + } + trimmed := value.Bytes() + update.StorageLen = int8(len(trimmed)) + copy(update.Storage[:], trimmed) + return update, true, nil +} diff --git a/rpc/jsonrpc/pbin_witness_stateless_test.go b/rpc/jsonrpc/pbin_witness_stateless_test.go new file mode 100644 index 00000000000..27207c398df --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_stateless_test.go @@ -0,0 +1,810 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package jsonrpc + +import ( + "bytes" + "context" + "maps" + "slices" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/commitment" + "github.com/erigontech/erigon/execution/protocol/rules" + "github.com/erigontech/erigon/execution/protocol/rules/ethash" + "github.com/erigontech/erigon/execution/protocol/rules/merge" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" +) + +// pbinStatelessState is the plain-state seam the binary engine reads while a +// witness is being built. It stands in for the domain layer: an absent key reads +// as deleted, exactly as a domain read does. +type pbinStatelessState struct { + branches map[string][]byte + accounts map[common.Address]commitment.Update + storage map[string]commitment.Update + code map[common.Address][]byte +} + +func newPBinStatelessState() *pbinStatelessState { + return &pbinStatelessState{ + branches: make(map[string][]byte), + accounts: make(map[common.Address]commitment.Update), + storage: make(map[string]commitment.Update), + code: make(map[common.Address][]byte), + } +} + +func (s *pbinStatelessState) clone() *pbinStatelessState { + c := newPBinStatelessState() + for k, v := range s.branches { + c.branches[k] = bytes.Clone(v) + } + maps.Copy(c.accounts, s.accounts) + maps.Copy(c.storage, s.storage) + for k, v := range s.code { + c.code[k] = bytes.Clone(v) + } + return c +} + +func (s *pbinStatelessState) Branch(prefix []byte) ([]byte, kv.Step, error) { + return s.branches[string(prefix)], 0, nil +} + +func (s *pbinStatelessState) PutBranch(prefix, data, prevData []byte) error { + s.branches[string(prefix)] = bytes.Clone(data) + return nil +} + +func (s *pbinStatelessState) Account(plainKey []byte) (*commitment.Update, error) { + update, ok := s.accounts[common.BytesToAddress(plainKey)] + if !ok { + return &commitment.Update{Flags: commitment.DeleteUpdate}, nil + } + return &update, nil +} + +func (s *pbinStatelessState) Storage(plainKey []byte) (*commitment.Update, error) { + update, ok := s.storage[string(plainKey)] + if !ok { + return &commitment.Update{Flags: commitment.DeleteUpdate}, nil + } + return &update, nil +} + +func (s *pbinStatelessState) Code(plainKey []byte) ([]byte, error) { + return s.code[common.BytesToAddress(plainKey)], nil +} + +func (s *pbinStatelessState) setAccount(addr common.Address, nonce, balance uint64, code []byte) { + update := commitment.Update{ + Flags: commitment.NonceUpdate | commitment.BalanceUpdate | commitment.CodeUpdate, + Nonce: nonce, + CodeHash: crypto.Keccak256Hash(code), + CodeSize: uint64(len(code)), + } + update.Balance.SetUint64(balance) + s.accounts[addr] = update + if len(code) > 0 { + s.code[addr] = bytes.Clone(code) + } +} + +func (s *pbinStatelessState) dropAccount(addr common.Address) { + delete(s.accounts, addr) + delete(s.code, addr) + for key := range s.storage { + if bytes.HasPrefix([]byte(key), addr[:]) { + delete(s.storage, key) + } + } +} + +func (s *pbinStatelessState) setStorage(addr common.Address, slot common.Hash, value uint64) { + key := string(append(bytes.Clone(addr[:]), slot[:]...)) + if value == 0 { + delete(s.storage, key) + return + } + var v uint256.Int + v.SetUint64(value) + trimmed := v.Bytes() + update := commitment.Update{Flags: commitment.StorageUpdate, StorageLen: int8(len(trimmed))} + copy(update.Storage[:], trimmed) + s.storage[key] = update +} + +// pbinStatelessProcess folds the state the way the domain layer does — ModeDirect, +// so every value comes back through the context rather than the touch. +func pbinStatelessProcess(t *testing.T, state *pbinStatelessState, plainKeys [][]byte) []byte { + t.Helper() + trie, updates := commitment.InitializeTrieAndUpdates(commitment.ModeDirect, t.TempDir(), + commitment.TrieConfig{Variant: commitment.VariantBinPatriciaTrie}) + defer trie.Release() + trie.ResetContext(state) + for _, key := range plainKeys { + updates.TouchPlainKeyDirect(string(key), &commitment.Update{}) + } + root, err := trie.Process(context.Background(), updates, "", nil, commitment.WarmupConfig{}) + require.NoError(t, err) + return bytes.Clone(root) +} + +// pbinStatelessWitness captures the witness of the accessed keys and prunes it to +// their proof paths, which is the node set debug_executionWitness returns. +func pbinStatelessWitness(t *testing.T, state *pbinStatelessState, accessed [][]byte) ([][]byte, []byte) { + t.Helper() + return pbinStatelessWitnessRemoving(t, state, accessed, nil) +} + +// pbinStatelessWitnessRemoving is the same capture for a block that removes +// accounts. The pass reads the parent state, where a removed account still +// looks live, so buildWitnessTrie has to name them — see commitment.PBinWitnessBlock. +func pbinStatelessWitnessRemoving(t *testing.T, state *pbinStatelessState, accessed [][]byte, removed []common.Address) ([][]byte, []byte) { + t.Helper() + trie, updates := commitment.InitializeTrieAndUpdates(commitment.ModeDirect, t.TempDir(), + commitment.TrieConfig{Variant: commitment.VariantBinPatriciaTrie}) + defer trie.Release() + trie.ResetContext(state) + for _, key := range accessed { + updates.TouchPlainKeyDirect(string(key), &commitment.Update{}) + } + capturer, ok := trie.(interface { + Witnesses(ctx context.Context, updates *commitment.Updates, produceExclusionProofs bool, logPrefix string) ([][]byte, [][]byte, []byte, error) + }) + require.True(t, ok, "the binary trie captures no witness") + if len(removed) > 0 { + setter, ok := trie.(interface { + SetWitnessBlock(commitment.PBinWitnessBlock) + }) + require.True(t, ok, "the binary trie takes no witness block") + block := commitment.PBinWitnessBlock{Removed: make(map[string]struct{}, len(removed))} + for _, addr := range removed { + block.Removed[string(addr[:])] = struct{}{} + } + setter.SetWitnessBlock(block) + } + + full, provedKeys, root, err := capturer.Witnesses(context.Background(), updates, false, "") + require.NoError(t, err) + lean, err := commitment.PBinWitnessNodesForKeys(full, root, provedKeys) + require.NoError(t, err) + return lean, bytes.Clone(root) +} + +func pbinStatelessAddr(b byte) common.Address { + var addr common.Address + addr[0], addr[19] = b, b + return addr +} + +func pbinStatelessSlot(n uint64) common.Hash { + var v uint256.Int + v.SetUint64(n) + return common.Hash(v.Bytes32()) +} + +func pbinStatelessSlotBytes(n uint64) []byte { + slot := pbinStatelessSlot(n) + return slot[:] +} + +// pbinStatelessCorpus is the pre-state every test in this file reads: an EOA, a +// contract whose code spans a few chunks, a larger contract spanning many, and +// storage in both the account header and the storage zone. +type pbinStatelessCorpus struct { + state *pbinStatelessState + eoa common.Address + contract common.Address + big common.Address + fresh common.Address + code []byte + bigCode []byte +} + +func pbinStatelessNewCorpus() *pbinStatelessCorpus { + c := &pbinStatelessCorpus{ + state: newPBinStatelessState(), + eoa: pbinStatelessAddr(0x11), + contract: pbinStatelessAddr(0x22), + big: pbinStatelessAddr(0x33), + fresh: pbinStatelessAddr(0x44), + code: bytes.Repeat([]byte{0x60, 0x01}, 100), + bigCode: bytes.Repeat([]byte{0x5b}, 5000), + } + c.state.setAccount(c.eoa, 7, 1_000_000, nil) + c.state.setAccount(c.contract, 1, 500, c.code) + c.state.setAccount(c.big, 1, 900, c.bigCode) + for _, slot := range []uint64{1, 63, 64, 1 << 20} { + c.state.setStorage(c.contract, pbinStatelessSlot(slot), slot+1) + } + return c +} + +// accessed is the key set the block touches: reads and writes both, which is what +// buildWitnessTrie folds over. +func (c *pbinStatelessCorpus) accessed() [][]byte { + keys := [][]byte{c.eoa[:], c.contract[:], c.big[:], c.fresh[:]} + for _, slot := range []uint64{1, 63, 64, 1 << 20, 999} { + s := pbinStatelessSlot(slot) + keys = append(keys, append(bytes.Clone(c.contract[:]), s[:]...)) + } + return keys +} + +func (c *pbinStatelessCorpus) verifier(t *testing.T) (*pbinWitnessStateless, [][]byte, common.Hash) { + t.Helper() + pbinStatelessProcess(t, c.state, c.accessed()) + nodes, root := pbinStatelessWitness(t, c.state, c.accessed()) + return pbinStatelessVerifierOver(t, nodes, root), nodes, common.BytesToHash(root) +} + +func pbinStatelessVerifierOver(t *testing.T, nodes [][]byte, root []byte) *pbinWitnessStateless { + t.Helper() + // Codes stays empty on purpose: under bin the chunk leaves are the code + // source, so every code read here has to come out of State alone. + result := &ExecutionWitnessResult{State: make([]hexutil.Bytes, len(nodes))} + for i, node := range nodes { + result.State[i] = node + } + stateless, err := newPBinWitnessStateless(result, common.BytesToHash(root)) + require.NoError(t, err) + return stateless +} + +// TestPBinWitnessStatelessResolvesAccessedState: the witness alone answers every +// account, slot and code read the block made. +func TestPBinWitnessStatelessResolvesAccessedState(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + stateless, _, _ := c.verifier(t) + + eoa, err := stateless.ReadAccountData(accounts.InternAddress(c.eoa)) + require.NoError(t, err) + require.NotNil(t, eoa) + require.Equal(t, uint64(7), eoa.Nonce) + require.Equal(t, uint64(1_000_000), eoa.Balance.Uint64()) + require.Equal(t, crypto.Keccak256Hash(nil), eoa.CodeHash.Value()) + + contract, err := stateless.ReadAccountData(accounts.InternAddress(c.contract)) + require.NoError(t, err) + require.NotNil(t, contract) + require.Equal(t, crypto.Keccak256Hash(c.code), contract.CodeHash.Value()) + + for _, tc := range []struct { + addr common.Address + want []byte + }{ + {c.eoa, []byte{}}, + {c.contract, c.code}, + {c.big, c.bigCode}, + } { + code, err := stateless.ReadAccountCode(accounts.InternAddress(tc.addr)) + require.NoError(t, err) + require.Equal(t, tc.want, code, "code of %x", tc.addr) + size, err := stateless.ReadAccountCodeSize(accounts.InternAddress(tc.addr)) + require.NoError(t, err) + require.Equal(t, len(tc.want), size) + } + + for _, slot := range []uint64{1, 63, 64, 1 << 20} { + value, ok, err := stateless.ReadAccountStorage(accounts.InternAddress(c.contract), + accounts.InternKey(pbinStatelessSlot(slot))) + require.NoError(t, err) + require.True(t, ok, "slot %d is absent", slot) + require.Equal(t, slot+1, value.Uint64()) + } +} + +// TestPBinWitnessStatelessAbsentResolvesWithoutError: absence is proved by the +// nodes on the way, so it resolves rather than erroring — and an absent read is +// not the same answer as an unresolved one. +func TestPBinWitnessStatelessAbsentResolvesWithoutError(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + stateless, _, _ := c.verifier(t) + + acc, err := stateless.ReadAccountData(accounts.InternAddress(c.fresh)) + require.NoError(t, err) + require.Nil(t, acc) + + value, ok, err := stateless.ReadAccountStorage(accounts.InternAddress(c.contract), + accounts.InternKey(pbinStatelessSlot(999))) + require.NoError(t, err) + require.False(t, ok) + require.True(t, value.IsZero()) + + code, err := stateless.ReadAccountCode(accounts.InternAddress(c.fresh)) + require.NoError(t, err) + require.Empty(t, code) +} + +// TestPBinWitnessStatelessMissingNodeErrors: dropping a node has to make the read +// that needs it fail. An empty read there would hash a wrong subtree into the +// post-state root and report success. +func TestPBinWitnessStatelessMissingNodeErrors(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + _, nodes, root := c.verifier(t) + + require.NotEmpty(t, nodes) + broke := 0 + for drop := range nodes { + trimmed := make([]hexutil.Bytes, 0, len(nodes)-1) + for i, node := range nodes { + if i != drop { + trimmed = append(trimmed, node) + } + } + stateless, err := newPBinWitnessStateless(&ExecutionWitnessResult{State: trimmed}, root) + if err != nil { + broke++ // the root node itself: the decode refuses before any read + continue + } + if pbinStatelessReadsAll(t, stateless, c) != nil { + broke++ + } + } + require.Equal(t, len(nodes), broke, "a node can be dropped without any read noticing") +} + +// pbinStatelessReadsAll replays every read the corpus makes and returns the first +// failure. +func pbinStatelessReadsAll(t *testing.T, s *pbinWitnessStateless, c *pbinStatelessCorpus) error { + t.Helper() + for _, addr := range []common.Address{c.eoa, c.contract, c.big, c.fresh} { + if _, err := s.ReadAccountData(accounts.InternAddress(addr)); err != nil { + return err + } + if _, err := s.ReadAccountCode(accounts.InternAddress(addr)); err != nil { + return err + } + } + for _, slot := range []uint64{1, 63, 64, 1 << 20, 999} { + if _, _, err := s.ReadAccountStorage(accounts.InternAddress(c.contract), + accounts.InternKey(pbinStatelessSlot(slot))); err != nil { + return err + } + } + return nil +} + +// TestPBinWitnessStatelessPostStateRoot is the gate the whole verifier exists +// for: the block's writes replayed over the witness reach the root the same +// writes reach over full state. +func TestPBinWitnessStatelessPostStateRoot(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + stateless, _, parentRoot := c.verifier(t) + + deployed := bytes.Repeat([]byte{0x60, 0x02}, 40) + writes := func(t *testing.T, s *pbinWitnessStateless) { + t.Helper() + eoa := accounts.InternAddress(c.eoa) + acc, err := s.ReadAccountData(eoa) + require.NoError(t, err) + acc.Nonce, acc.Balance = 8, *uint256.NewInt(900_000) + require.NoError(t, s.UpdateAccountData(eoa, nil, acc)) + + contract := accounts.InternAddress(c.contract) + contractAcc, err := s.ReadAccountData(contract) + require.NoError(t, err) + contractAcc.Balance = *uint256.NewInt(600) + require.NoError(t, s.UpdateAccountData(contract, nil, contractAcc)) + require.NoError(t, s.WriteAccountStorage(contract, 0, accounts.InternKey(pbinStatelessSlot(1)), + uint256.Int{}, *uint256.NewInt(0xAB))) + // Zeroing a slot the witness holds removes its leaf; one the witness + // proves absent must not gain a leaf. + require.NoError(t, s.WriteAccountStorage(contract, 0, accounts.InternKey(pbinStatelessSlot(64)), + uint256.Int{}, uint256.Int{})) + require.NoError(t, s.WriteAccountStorage(contract, 0, accounts.InternKey(pbinStatelessSlot(999)), + uint256.Int{}, uint256.Int{})) + + fresh := accounts.InternAddress(c.fresh) + require.NoError(t, s.CreateContract(fresh)) + require.NoError(t, s.UpdateAccountCode(fresh, 0, accounts.InternCodeHash(crypto.Keccak256Hash(deployed)), deployed)) + freshAcc := &accounts.Account{Nonce: 1, CodeHash: accounts.InternCodeHash(crypto.Keccak256Hash(deployed))} + freshAcc.Balance.SetUint64(42) + require.NoError(t, s.UpdateAccountData(fresh, nil, freshAcc)) + } + writes(t, stateless) + + got, err := stateless.Finalize(context.Background()) + require.NoError(t, err) + + full := c.state.clone() + full.setAccount(c.eoa, 8, 900_000, nil) + full.setAccount(c.contract, 1, 600, c.code) + full.setStorage(c.contract, pbinStatelessSlot(1), 0xAB) + full.setStorage(c.contract, pbinStatelessSlot(64), 0) + full.setAccount(c.fresh, 1, 42, deployed) + want := pbinStatelessProcess(t, full, [][]byte{ + c.eoa[:], c.contract[:], c.fresh[:], + append(bytes.Clone(c.contract[:]), pbinStatelessSlotBytes(1)...), + append(bytes.Clone(c.contract[:]), pbinStatelessSlotBytes(64)...), + append(bytes.Clone(c.contract[:]), pbinStatelessSlotBytes(999)...), + }) + + require.Equal(t, common.BytesToHash(want), got) + require.NotEqual(t, parentRoot, got, "the writes do not move the root, so the test proves nothing") +} + +// TestPBinWitnessStatelessRemovesOnTreeAccount: a block that clears an account +// the parent state holds reaches, over the witness alone, the root the domain +// fold reaches over full state. +func TestPBinWitnessStatelessRemovesOnTreeAccount(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + pbinStatelessProcess(t, c.state, c.accessed()) + nodes, root := pbinStatelessWitnessRemoving(t, c.state, c.accessed(), []common.Address{c.contract}) + stateless := pbinStatelessVerifierOver(t, nodes, root) + + require.NoError(t, stateless.DeleteAccount(accounts.InternAddress(c.contract), nil)) + + got, err := stateless.Finalize(context.Background()) + require.NoError(t, err) + + full := c.state.clone() + full.dropAccount(c.contract) + want := pbinStatelessProcess(t, full, [][]byte{c.contract[:]}) + + require.Equal(t, common.BytesToHash(want), got) + require.NotEqual(t, common.BytesToHash(root), got, "the removal does not move the root, so the test proves nothing") +} + +// TestPBinWitnessStatelessHasStorageZoneProbe: the probe slot is what the +// builder touches to bring an account's storage zone into the witness, so a +// zone slot the block never read still answers the CREATE-collision predicate — +// and the probe's own key is not the slot that holds the value. +func TestPBinWitnessStatelessHasStorageZoneProbe(t *testing.T) { + t.Parallel() + + state := newPBinStatelessState() + zoneSlot := pbinStatelessAddr(0x52) + bare := pbinStatelessAddr(0x53) + for _, addr := range []common.Address{zoneSlot, bare} { + state.setAccount(addr, 0, 1, nil) + } + state.setStorage(zoneSlot, pbinStatelessSlot(1<<20), 9) + + both := [][]byte{zoneSlot[:], bare[:]} + pbinStatelessProcess(t, state, append(slices.Clone(both), + append(bytes.Clone(zoneSlot[:]), pbinStatelessSlotBytes(1<<20)...))) + + probe := commitment.PBinStorageZoneProbeSlot() + nodes, root := pbinStatelessWitness(t, state, append(slices.Clone(both), + append(bytes.Clone(zoneSlot[:]), probe[:]...), + append(bytes.Clone(bare[:]), probe[:]...))) + stateless := pbinStatelessVerifierOver(t, nodes, root) + + has, err := stateless.HasStorage(accounts.InternAddress(zoneSlot)) + require.NoError(t, err) + require.True(t, has, "the probe proves the zone occupied even though it names another slot") + + has, err = stateless.HasStorage(accounts.InternAddress(bare)) + require.NoError(t, err) + require.False(t, has, "the probe proves an empty zone empty") +} + +// TestPBinWitnessStatelessHasStorage: EIP-7610's CREATE-collision predicate is +// answered from the leaves, so a pre-state slot the block never wrote still +// counts. The header slots resolve off the account's own proof path; the storage +// zone answers for a witness whose keys walked into it, which is what the +// builder's probe is for, and never reports a neighbour's zone as this +// account's. +func TestPBinWitnessStatelessHasStorage(t *testing.T) { + t.Parallel() + + state := newPBinStatelessState() + headerSlot := pbinStatelessAddr(0x51) + zoneSlot := pbinStatelessAddr(0x52) + bare := pbinStatelessAddr(0x53) + for _, addr := range []common.Address{headerSlot, zoneSlot, bare} { + state.setAccount(addr, 0, 1, nil) + } + state.setStorage(headerSlot, pbinStatelessSlot(3), 9) + state.setStorage(zoneSlot, pbinStatelessSlot(1<<20), 9) + + accounts3 := [][]byte{headerSlot[:], zoneSlot[:], bare[:]} + pbinStatelessProcess(t, state, append(slices.Clone(accounts3), + append(bytes.Clone(headerSlot[:]), pbinStatelessSlotBytes(3)...), + append(bytes.Clone(zoneSlot[:]), pbinStatelessSlotBytes(1<<20)...))) + + // The block reads the three accounts and no slot, which is what a CREATE + // colliding on an address touches. + nodes, root := pbinStatelessWitness(t, state, accounts3) + stateless := pbinStatelessVerifierOver(t, nodes, root) + + has, err := stateless.HasStorage(accounts.InternAddress(headerSlot)) + require.NoError(t, err) + require.True(t, has, "a header slot sits on the account's own proof path") + + has, err = stateless.HasStorage(accounts.InternAddress(bare)) + require.NoError(t, err) + require.False(t, has) + + require.NoError(t, stateless.WriteAccountStorage(accounts.InternAddress(bare), 0, + accounts.InternKey(pbinStatelessSlot(1<<20)), uint256.Int{}, *uint256.NewInt(1))) + has, err = stateless.HasStorage(accounts.InternAddress(bare)) + require.NoError(t, err) + require.True(t, has, "the block's own write counts") + + // A block that does touch the slot puts the storage zone in the witness, and + // the zone answers for the account that owns it and no other. + zoneNodes, zoneRoot := pbinStatelessWitness(t, state, append(slices.Clone(accounts3), + append(bytes.Clone(zoneSlot[:]), pbinStatelessSlotBytes(1<<20)...))) + withZone := pbinStatelessVerifierOver(t, zoneNodes, zoneRoot) + + has, err = withZone.HasStorage(accounts.InternAddress(zoneSlot)) + require.NoError(t, err) + require.True(t, has) + + has, err = withZone.HasStorage(accounts.InternAddress(bare)) + require.NoError(t, err) + require.False(t, has, "a neighbour's zone leaf is not this account's storage") +} + +// TestPBinWitnessStatelessCreateOverStoredAccountRefused: the chain drops an +// address's whole storage prefix on CREATE, which no plain-key update here can +// express. A create over storage the witness proves is refused rather than +// answered with a root that keeps the leaves the chain removed. +func TestPBinWitnessStatelessCreateOverStoredAccountRefused(t *testing.T) { + t.Parallel() + + state := newPBinStatelessState() + stored := pbinStatelessAddr(0x61) + bare := pbinStatelessAddr(0x62) + for _, addr := range []common.Address{stored, bare} { + state.setAccount(addr, 0, 1, nil) + } + state.setStorage(stored, pbinStatelessSlot(3), 9) + + both := [][]byte{stored[:], bare[:]} + pbinStatelessProcess(t, state, append(slices.Clone(both), + append(bytes.Clone(stored[:]), pbinStatelessSlotBytes(3)...))) + + nodes, root := pbinStatelessWitness(t, state, both) + stateless := pbinStatelessVerifierOver(t, nodes, root) + + require.NoError(t, stateless.CreateContract(accounts.InternAddress(bare)), + "a create over an account with no storage is the ordinary case") + + require.NoError(t, stateless.DeleteAccount(accounts.InternAddress(stored), nil)) + require.Error(t, stateless.CreateContract(accounts.InternAddress(stored)), + "an in-block removal does not make the pre-state leaves go away") +} + +// TestPBinWitnessStatelessRemovesAccountCreatedInBlock: an account the witness +// proves absent was created and dropped inside the block, so it leaves no leaf +// behind and the root must not move. +func TestPBinWitnessStatelessRemovesAccountCreatedInBlock(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + stateless, _, parentRoot := c.verifier(t) + + require.NoError(t, stateless.DeleteAccount(accounts.InternAddress(c.fresh), nil)) + + got, err := stateless.Finalize(context.Background()) + require.NoError(t, err) + require.Equal(t, parentRoot, got) +} + +// TestPBinWitnessStatelessRefundsRemovedAccount: FinalizeTx runs per transaction, +// so an account emptied under EIP-161 in one transaction and funded again in a +// later one reaches the writer as DeleteAccount then UpdateAccountData, with no +// CreateContract between them. The later write wins, as it does when the domain +// layer merges the same pair. +func TestPBinWitnessStatelessRefundsRemovedAccount(t *testing.T) { + t.Parallel() + + c := pbinStatelessNewCorpus() + stateless, _, parentRoot := c.verifier(t) + + eoa := accounts.InternAddress(c.eoa) + acc, err := stateless.ReadAccountData(eoa) + require.NoError(t, err) + require.NoError(t, stateless.DeleteAccount(eoa, nil)) + + acc.Nonce, acc.Balance = 0, *uint256.NewInt(555) + require.NoError(t, stateless.UpdateAccountData(eoa, nil, acc)) + + got, err := stateless.Finalize(context.Background()) + require.NoError(t, err) + + full := c.state.clone() + full.setAccount(c.eoa, 0, 555, nil) + want := pbinStatelessProcess(t, full, [][]byte{c.eoa[:]}) + + require.Equal(t, common.BytesToHash(want), got) + require.NotEqual(t, parentRoot, got, "the writes do not move the root, so the test proves nothing") +} + +// pbinVerifyWithdrawalGwei is the only state the gate's test block moves. A +// withdrawal keeps the expected post-state root arithmetic instead of gas +// accounting, while still running the full replay. +const pbinVerifyWithdrawalGwei = 3 + +// pbinVerifyChainConfig is post-merge Shanghai: a PoS header pays no block +// reward, and neither the Cancun beacon-root contract nor the Prague blockhash +// contract exists to be called out of a witness that does not carry it. +func pbinVerifyChainConfig() *chain.Config { + return &chain.Config{ + ChainID: uint256.NewInt(1337), + Rules: chain.EtHashRules, + HomesteadBlock: common.NewUint64(0), + TangerineWhistleBlock: common.NewUint64(0), + SpuriousDragonBlock: common.NewUint64(0), + ByzantiumBlock: common.NewUint64(0), + ConstantinopleBlock: common.NewUint64(0), + PetersburgBlock: common.NewUint64(0), + IstanbulBlock: common.NewUint64(0), + BerlinBlock: common.NewUint64(0), + LondonBlock: common.NewUint64(0), + TerminalTotalDifficulty: uint256.NewInt(0), + TerminalTotalDifficultyPassed: true, + ShanghaiTime: common.NewUint64(0), + Ethash: new(chain.EthashConfig), + } +} + +func pbinVerifyEngine() rules.Engine { return merge.New(ethash.NewFaker()) } + +func pbinVerifyBlock(t *testing.T, postRoot common.Hash, to common.Address) *types.Block { + t.Helper() + header := &types.Header{ + Root: postRoot, + Number: *uint256.NewInt(1), + Difficulty: uint256.Int{}, // PoS: no block reward + GasLimit: 30_000_000, + Time: 1, + BaseFee: uint256.NewInt(7), + } + withdrawals := []*types.Withdrawal{{Index: 0, Validator: 0, Address: to, Amount: pbinVerifyWithdrawalGwei}} + return types.NewBlock(header, nil, nil, nil, withdrawals) +} + +// pbinVerifyGateCase is the corpus of the gate tests: the witness is pruned to +// the one account the block credits, so every node in it is on that account's +// path and no removal can go unnoticed. +type pbinVerifyGateCase struct { + corpus *pbinStatelessCorpus + nodes [][]byte + parentRoot common.Hash + block *types.Block +} + +func pbinVerifyNewGateCase(t *testing.T) *pbinVerifyGateCase { + t.Helper() + c := pbinStatelessNewCorpus() + pbinStatelessProcess(t, c.state, c.accessed()) + nodes, parentRoot := pbinStatelessWitness(t, c.state, [][]byte{c.eoa[:]}) + + credited := c.state.clone() + credited.setAccount(c.eoa, 7, 1_000_000+pbinVerifyWithdrawalGwei*uint64(common.GWei), nil) + postRoot := pbinStatelessProcess(t, credited, [][]byte{c.eoa[:]}) + require.NotEqual(t, parentRoot, postRoot, "the withdrawal does not move the root, so the gate proves nothing") + + return &pbinVerifyGateCase{ + corpus: c, + nodes: nodes, + parentRoot: common.BytesToHash(parentRoot), + block: pbinVerifyBlock(t, common.BytesToHash(postRoot), c.eoa), + } +} + +func (g *pbinVerifyGateCase) result(nodes [][]byte) *ExecutionWitnessResult { + result := &ExecutionWitnessResult{ + State: make([]hexutil.Bytes, len(nodes)), + Keys: []hexutil.Bytes{g.corpus.eoa[:]}, + } + for i, node := range nodes { + result.State[i] = node + } + return result +} + +func (g *pbinVerifyGateCase) verify(result *ExecutionWitnessResult, block *types.Block) error { + return verifyWitnessAgainstBlock(context.Background(), result, block, g.parentRoot, + pbinVerifyChainConfig(), pbinVerifyEngine(), true /* binTrie */) +} + +// TestPBinWitnessVerifyGateAcceptsGoodWitness: the block replayed from the +// witness alone reaches the header's post-state root, so the gate lets it +// through. +func TestPBinWitnessVerifyGateAcceptsGoodWitness(t *testing.T) { + t.Parallel() + + g := pbinVerifyNewGateCase(t) + require.NoError(t, g.verify(g.result(g.nodes), g.block)) +} + +// TestPBinWitnessVerifyGateRejectsWrongRoot: a witness that replays to another +// root is refused, which is what stops it from being returned. +func TestPBinWitnessVerifyGateRejectsWrongRoot(t *testing.T) { + t.Parallel() + + g := pbinVerifyNewGateCase(t) + wrongRoot := pbinVerifyBlock(t, common.HexToHash("0xdead"), g.corpus.eoa) + require.ErrorContains(t, g.verify(g.result(g.nodes), wrongRoot), "state root mismatch") +} + +// TestPBinWitnessVerifyGateRejectsTruncatedWitness: a node the replay reads is +// load-bearing, so dropping it has to fail the gate rather than replay to a root +// that happens to match. The pruner also keeps the sibling hanging off each +// branch on the path, which a block that removes nothing never reads; those are +// the only drops the gate may tolerate, and a binary branch has one of them per +// level, so they cannot outnumber the path itself. +func TestPBinWitnessVerifyGateRejectsTruncatedWitness(t *testing.T) { + t.Parallel() + + g := pbinVerifyNewGateCase(t) + require.NotEmpty(t, g.nodes) + var tolerated, rejected int + for drop := range g.nodes { + trimmed := make([][]byte, 0, len(g.nodes)-1) + for i, node := range g.nodes { + if i != drop { + trimmed = append(trimmed, node) + } + } + if g.verify(g.result(trimmed), g.block) != nil { + rejected++ + continue + } + tolerated++ + } + require.Positive(t, rejected) + require.Less(t, tolerated, rejected, "the gate tolerated more drops than the path has siblings") +} + +// TestPBinWitnessVerifyGateChecksKeys: the gate still refuses a witness whose +// keys[] omits a leaf the re-execution resolved. +func TestPBinWitnessVerifyGateChecksKeys(t *testing.T) { + t.Parallel() + + g := pbinVerifyNewGateCase(t) + result := g.result(g.nodes) + result.Keys = nil + require.ErrorContains(t, g.verify(result, g.block), g.corpus.eoa.Hex()) +} + +// TestWitnessVerifySkippedOnlyUnderHex: the same env var may skip the gate +// under hex but never under bin — see witnessVerifySkipped for why. +func TestWitnessVerifySkippedOnlyUnderHex(t *testing.T) { + require.False(t, witnessVerifySkipped(false /* binTrie */), "hex verification is off by default") + require.False(t, witnessVerifySkipped(true /* binTrie */), "bin verification is off by default") + + t.Setenv("ERIGON_WITNESS_NO_VERIFY", "true") + require.True(t, witnessVerifySkipped(false /* binTrie */)) + require.False(t, witnessVerifySkipped(true /* binTrie */)) +} diff --git a/rpc/jsonrpc/pbin_witness_whale_test.go b/rpc/jsonrpc/pbin_witness_whale_test.go new file mode 100644 index 00000000000..2b3a5639a02 --- /dev/null +++ b/rpc/jsonrpc/pbin_witness_whale_test.go @@ -0,0 +1,214 @@ +package jsonrpc + +// A 1,000-slot contract read at three depths, hex against bin. +// +// Storage layout, not slot count, is what moves a binary witness: slots below 64 +// sit in the account header under 34-byte keys sharing the account's stem, while +// everything above lands in the storage zone under 66-byte keys, one stem per +// 256-slot group. Mapping slots are keccak images, so they scatter one per group. +// +// The contract SLOADs a countdown of slots so one transaction touches N of them. + +import ( + "fmt" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jinzhu/copier" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/execmodule/execmoduletester" + "github.com/erigontech/erigon/execution/tests/blockgen" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/rpc/rpccfg" +) + +const pbinWhaleSlots = 1000 + +// pbinWhaleReader builds runtime that reads `n` slots. Sequential walks slot +// numbers directly; mapping hashes each index first, which is what puts every +// slot in its own storage-zone group. +func pbinWhaleReader(n int, mapping bool) []byte { + code := []byte{0x61, byte(n >> 8), byte(n), 0x5b, 0x80} // PUSH2 n; JUMPDEST; DUP1 + if mapping { + code = append(code, + 0x60, 0x00, 0x52, // PUSH1 0; MSTORE -> mem[0] = i + 0x60, 0x20, 0x60, 0x00, 0x20, // PUSH1 32; PUSH1 0; SHA3 -> keccak(i) + ) + } + return append(code, + 0x54, 0x50, // SLOAD; POP + 0x60, 0x01, 0x90, 0x03, // PUSH1 1; SWAP1; SUB -> i-1 + 0x80, 0x60, 0x03, 0x57, // DUP1; PUSH1 3; JUMPI + 0x00, // STOP + ) +} + +func pbinWhaleSlotKey(i int, mapping bool) common.Hash { + if !mapping { + return common.BigToHash(big.NewInt(int64(i))) + } + var buf [32]byte + big.NewInt(int64(i)).FillBytes(buf[:]) + return crypto.Keccak256Hash(buf[:]) +} + +type pbinWhaleRow struct { + layout string + touched int + hexNodes, hexState, hexTotal int + binNodes, binTotal int + binLeaf, binBranch int + binHdr, binZone int +} + +var pbinWhaleCases = []struct { + layout string + mapping bool + touch int +}{ + {"sequential", false, 8}, + {"sequential", false, 64}, + {"sequential", false, pbinWhaleSlots}, + {"mapping", true, 8}, + {"mapping", true, 64}, + {"mapping", true, pbinWhaleSlots}, +} + +// pbinWhaleChain allocates both contracts with 1,000 slots at genesis, then reads +// each depth in its own block so every measured witness is a pure read. +func pbinWhaleChain(t *testing.T) *pbinWitnessChain { + t.Helper() + bankKey, err := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + require.NoError(t, err) + bank := crypto.PubkeyToAddress(bankKey.PublicKey) + funds, ok := new(big.Int).SetString("100000000000000000000", 10) + require.True(t, ok) + + alloc := types.GenesisAlloc{bank: {Balance: funds}} + addrs := map[bool]map[int]common.Address{false: {}, true: {}} + for _, mapping := range []bool{false, true} { + storage := make(map[common.Hash]common.Hash, pbinWhaleSlots) + for i := 1; i <= pbinWhaleSlots; i++ { + storage[pbinWhaleSlotKey(i, mapping)] = common.BigToHash(big.NewInt(int64(i))) + } + for _, c := range pbinWhaleCases { + if c.mapping != mapping { + continue + } + a := common.BigToAddress(big.NewInt(int64(0x9000 + len(alloc)))) + alloc[a] = types.GenesisAccount{ + Balance: big.NewInt(1), + Code: pbinWhaleReader(c.touch, mapping), + Storage: storage, + } + addrs[mapping][c.touch] = a + } + } + + chainConfig := new(chain.Config) + require.NoError(t, copier.CopyWithOption(chainConfig, chain.TestChainBerlinConfig, copier.Option{DeepCopy: true})) + m := execmoduletester.New(t, + execmoduletester.WithGenesisSpec(&types.Genesis{ + Config: chainConfig, Alloc: alloc, GasLimit: 60_000_000, + }), + execmoduletester.WithKey(bankKey)) + + signer := types.LatestSignerForChainID(nil) + gasPrice := uint256.NewInt(1_000_000_000) + pack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, len(pbinWhaleCases), + func(i int, b *blockgen.BlockGen) { + c := pbinWhaleCases[i] + to := addrs[c.mapping][c.touch] + txn := &types.LegacyTx{CommonTx: types.CommonTx{ + Nonce: b.TxNonce(bank), To: &to, GasLimit: 30_000_000, + }} + txn.GasPrice = *gasPrice + signed, err := types.SignTx(txn, *signer, bankKey) + require.NoError(t, err) + b.AddTx(signed) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(pack)) + return &pbinWitnessChain{m: m, pack: pack} +} + +func TestPBinWhaleWitness(t *testing.T) { + withCommitmentHistory(t) + rows := make([]pbinWhaleRow, len(pbinWhaleCases)) + for i, c := range pbinWhaleCases { + rows[i].layout, rows[i].touched = c.layout, c.touch + } + + t.Run("hex", func(t *testing.T) { + c := pbinWhaleChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i := range pbinWhaleCases { + w := pbinWitnessOf(t, api, uint64(i+1)) + rows[i].hexNodes = len(w.State) + rows[i].hexState = sumBytes(w.State) + rows[i].hexTotal = rows[i].hexState + sumBytes(w.Codes) + sumBytes(w.Headers) + } + }) + + t.Run("bin", func(t *testing.T) { + withBinCommitmentDatadir(t) + c := pbinWhaleChain(t) + enableCommitmentHistoryFlag(t, c.m.DB) + api := NewPrivateDebugAPI(newBaseApiForTest(c.m), c.m.DB, nil, &rpccfg.DebugApiConfig{}) + for i := range pbinWhaleCases { + w := pbinWitnessOf(t, api, uint64(i+1)) + r := &rows[i] + r.binNodes = len(w.State) + for _, n := range w.State { + r.binTotal += len(n) + k := pbinLeafKeyOf(n) + if k == nil { + r.binBranch += len(n) + continue + } + r.binLeaf += len(n) + switch sub := k[len(k)-1]; { + case k[0] == 0xFF: + r.binZone++ + case k[0] == 0x00 && sub >= 64 && sub < 128: + r.binHdr++ + } + } + r.binTotal += sumBytes(w.Headers) + } + // The property the table exists to show: a slot's number, not its count, + // decides the zone. Slots under 64 sit in the account's header window; + // everything else, and every keccak-mapped slot, gets its own storage-zone + // group. + for i, c := range pbinWhaleCases { + r := &rows[i] + if c.mapping { + require.Zero(t, r.binHdr, "%s/%d: a mapped slot cannot reach the header window", c.layout, c.touch) + require.NotZero(t, r.binZone, "%s/%d: mapped slots must land in the storage zone", c.layout, c.touch) + continue + } + require.NotZero(t, r.binHdr, "%s/%d: slots under 64 must land in the header window", c.layout, c.touch) + if c.touch < 64 { + require.Zero(t, r.binZone, "%s/%d: no slot reaches the storage zone", c.layout, c.touch) + } else { + require.NotZero(t, r.binZone, "%s/%d: slots from 64 up must land in the storage zone", c.layout, c.touch) + } + } + }) + + out := fmt.Sprintf("%d slots stored; one block per read depth\n", pbinWhaleSlots) + out += fmt.Sprintf("%-11s %6s | %7s %9s | %7s %9s %8s | %9s %9s %6s %6s\n", + "layout", "touch", "hexNod", "hex tot", "binNod", "bin tot", "bin/hex", "binLeafB", "binBrB", "hdr", "zone") + for _, r := range rows { + out += fmt.Sprintf("%-11s %6d | %7d %9d | %7d %9d %7.2fx | %9d %9d %6d %6d\n", + r.layout, r.touched, r.hexNodes, r.hexTotal, r.binNodes, r.binTotal, + float64(r.binTotal)/float64(max(r.hexTotal, 1)), r.binLeaf, r.binBranch, r.binHdr, r.binZone) + } + t.Log(out) +} diff --git a/rpc/jsonrpc/receipts/receipts_generator.go b/rpc/jsonrpc/receipts/receipts_generator.go index cf48f8f85f3..01690f58d4c 100644 --- a/rpc/jsonrpc/receipts/receipts_generator.go +++ b/rpc/jsonrpc/receipts/receipts_generator.go @@ -330,7 +330,7 @@ func (g *Generator) GetReceipt(ctx context.Context, cfg *chain.Config, tx kv.Tem var stateWriter state.StateWriter if calculatePostState && postState.CommitmentHistory { - sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } @@ -550,7 +550,7 @@ func (g *Generator) GetReceipts(ctx context.Context, cfg *chain.Config, tx kv.Te var stateWriter state.StateWriter if opts.CommitmentHistoryEnabled { - sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + sharedDomains, err = execctx.NewSharedDomains(ctx, tx, log.Root(), execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/testdata/hex_witness_baseline.json b/rpc/jsonrpc/testdata/hex_witness_baseline.json new file mode 100644 index 00000000000..304c3b5c138 --- /dev/null +++ b/rpc/jsonrpc/testdata/hex_witness_baseline.json @@ -0,0 +1,72 @@ +[ + { + "block": 1, + "shape": "plain transfer", + "nodes": 2, + "stateBytes": 118, + "codes": 1, + "codeBytes": 0, + "headers": 1, + "headerBytes": 502 + }, + { + "block": 2, + "shape": "deploy within one code-zone group", + "nodes": 4, + "stateBytes": 347, + "codes": 2, + "codeBytes": 8, + "headers": 1, + "headerBytes": 504 + }, + { + "block": 3, + "shape": "deploy crossing a group boundary", + "nodes": 4, + "stateBytes": 379, + "codes": 2, + "codeBytes": 8184, + "headers": 1, + "headerBytes": 504 + }, + { + "block": 4, + "shape": "storage write", + "nodes": 5, + "stateBytes": 518, + "codes": 2, + "codeBytes": 8, + "headers": 1, + "headerBytes": 505 + }, + { + "block": 5, + "shape": "SSTORE to zero", + "nodes": 6, + "stateBytes": 554, + "codes": 2, + "codeBytes": 8, + "headers": 1, + "headerBytes": 504 + }, + { + "block": 6, + "shape": "code read across a group boundary", + "nodes": 5, + "stateBytes": 518, + "codes": 2, + "codeBytes": 8184, + "headers": 1, + "headerBytes": 504 + }, + { + "block": 7, + "shape": "no transactions", + "nodes": 3, + "stateBytes": 295, + "codes": 1, + "codeBytes": 0, + "headers": 1, + "headerBytes": 504 + } +] diff --git a/rpc/rpchelper/commitment.go b/rpc/rpchelper/commitment.go index 12d522517d8..b00f1f58e4e 100644 --- a/rpc/rpchelper/commitment.go +++ b/rpc/rpchelper/commitment.go @@ -94,7 +94,7 @@ func (r *CommitmentReplay) ComputeCustomCommitmentFromStateHistory( } defer ttx.Rollback() - tsd, err := execctx.NewSharedDomains(ctx, ttx, r.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + tsd, err := execctx.NewSharedDomains(ctx, ttx, r.logger, execctx.WithoutDeferredBranchUpdates(), execctx.WithHexCommitmentOnly()) if err != nil { return nil, err } diff --git a/rpc/rpchelper/pbin_commitment_test.go b/rpc/rpchelper/pbin_commitment_test.go new file mode 100644 index 00000000000..f6bfbeb7d9d --- /dev/null +++ b/rpc/rpchelper/pbin_commitment_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon 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. +// +// Erigon 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 Erigon. If not, see . + +package rpchelper + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv/rawdbv3" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/statecfg" +) + +// Commitment replay recomputes roots with the hex trie over its own temporary +// aggregator, so it cannot serve a bin datadir. +func TestPBinCommitmentReplayRefusesBin(t *testing.T) { + // No t.Parallel: mutates process-global statecfg flags. + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + orig := statecfg.ExperimentalBinCommitment + origParallel, origStreaming := statecfg.ExperimentalParallelCommitment, statecfg.ExperimentalStreamingCommitment + t.Cleanup(func() { + statecfg.ExperimentalBinCommitment = orig + statecfg.ExperimentalParallelCommitment = origParallel + statecfg.ExperimentalStreamingCommitment = origStreaming + }) + statecfg.ExperimentalBinCommitment = true + // erigondb.toml resolution refuses bin combined with either: the bin trie is + // sequential-only, regardless of a process-wide parallel/streaming default. + statecfg.ExperimentalParallelCommitment = false + statecfg.ExperimentalStreamingCommitment = false + + // Fresh dirs: the replay resolves erigondb.toml itself, and a hex toml would + // be refused there instead of at the SharedDomains this test pins. + r := NewCommitmentReplay(datadir.New(t.TempDir()), rawdbv3.TxNums, log.New()) + _, err = r.ComputeCustomCommitmentFromStateHistory(t.Context(), tx, 0, nil) + require.ErrorIs(t, err, execctx.ErrBinCommitmentUnsupported) +}