Skip to content

perf(rpc): optimize storage proof generation with multi-key trie proofs - #4015

Merged
rodrodros merged 7 commits into
mainfrom
perf/rpc-get-storage-proof
Sep 10, 2026
Merged

rodrodros merged 7 commits into
mainfrom
perf/rpc-get-storage-proof

Conversation

@cicr99

@cicr99 cicr99 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Main Change

Before this PR, storage proof generation called Prove once per requested key. For requests with many classes, contracts, or storage keys, this meant repeating the same trie traversal from the root for each key, even when many keys shared prefixes.
This PR adds ProveMulti to both trie implementations. Instead of calling Prove independently for each requested key, the new method sorts keys by trie path, deduplicates them, and walks shared prefixes once while adding all required proof nodes to the proof set.

Other Changes

  • Updated RPC v9/v10 storage proof handlers to use ProveMulti for class, contract, and storage proofs.
  • Refactored storage proof validation so block and input checks happen before state access.
  • Added starknet_getStorageProof benchmarks focused on the high-volume cases this PR optimizes: many classes, many contracts, and mixed many-class/many-contract requests.
  • Added a trie2-specific combined contract metadata read path for leaf data, instead of doing multiple reads per contract to get that data.

Benchmarks

ProveMulti was initially implemented on top of the previous trie proof code, before #3945 was merged. Since #3945 also improved proof generation by reusing stored hashes, the storage proof benchmarks were re-ran after rebasing this PR on top of it and combining both solutions.

The table below compares three points:

Benchmark Run ns/op Speed-up vs Original B/op B/op reduction allocs/op allocs reduction
many_classes Original 11,044,434 - 798,741 - 23,231 -
  Multi-Proof only 1,161,500 9.51× 160,061 79.96% 3,626 84.39%
  Combined 109,598 100.77× 117,539 85.28% 2,360 89.84%
many_contracts Original 11,949,112 - 878,776 - 25,792 -
  Multi-Proof only 2,143,176 5.58× 240,104 72.68% 6,187 76.01%
  Combined 1,091,621 10.95× 197,576 77.52% 4,921 80.92%
many_classes_and_contracts Original 23,302,210 - 1,676,255 - 48,989 -
  Multi-Proof only 3,376,360 6.90× 398,930 76.20% 9,779 80.04%
  Combined 1,189,607 19.59× 313,879 81.27% 7,247 85.21%

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cicr99's task in 8m 37s —— View job


Review: PR #4015 — optimize storage proof generation with multi-key trie proofs

  • Gather context (diff, changed files)
  • Review core/trie/proof.go and core/trie2/proof.go (multi-key proof logic)
  • Review core/state/state_reader.go changes
  • Review rpc/v8, v9, v10 storage.go handler changes
  • Review tests for coverage/meaningfulness
  • Post final summary

Note: I was not able to get approval to run go build/go test in this sandbox (the full build also fails locally without the vendored Rust VM/compiler artifacts). The review below is based on careful manual tracing of the algorithm and cross-checking against the added tests; CI should be relied on to confirm compilation/test pass.

Summary

This is a solid, carefully implemented optimization. I traced through both proveMultiFrom implementations (core/trie and core/trie2) in detail:

  • The in-place slice filtering (keys[:0]/paths[:0] reuse pattern used repeatedly for continuingKeys/continuingPaths/matchingPaths/dropMSB) is the standard "filter in place" idiom. I verified writes always land at indices ≤ the read cursor and that sibling left/right sub-slices partition the backing array into disjoint, non-overlapping regions before recursing — so there's no aliasing corruption between recursive calls despite the shared backing arrays.
  • Non-membership / partial-prefix-match cases (key diverges partway through a compressed edge, or missing child) correctly stop descending while still adding the current node to the proof set, matching the single-key Prove semantics (including the root-vs-non-root "only add to proof if separately hashed" rule via addProofNode/isRoot).
  • Dedup + sort by trie path (slices.SortFunc + BitArray.Cmp / Path.Cmp) is correct since all keys are converted via FeltToKey/FeltToPath with the same fixed t.height, so lengths are always equal and comparison reduces to a numeric compare that matches left/right trie order.
  • The rpc/v8, v9, v10 handler changes are consistent across all three versions. Reordering isBlockSupported/dedup checks before acquiring HeadState() is a nice efficiency win (avoids opening a state reader for requests that fail early validation) and the corresponding test simplification (removing now-unnecessary BlockHeaderByNumber mock expectations) matches.
  • buildContractLeavesDataFromMetadata combines 3 separate reads (ContractClassHash/ContractNonce/ContractStorageTrie().Hash()) into a single ContractMetadata DB read when the state reader supports it, with graceful fallback via type-assertion for readers that don't implement it. Error handling for missing contracts (db.ErrKeyNotFound → skip, leaving a nil LeafData entry) is consistent with the pre-existing fallback path.
  • Test coverage is a highlight: TestProveMultiMatchesRepeatedProve in both trie packages asserts ProveMulti output is byte-for-byte equivalent to repeated single-key Prove calls across unsorted/duplicate/mixed-existence/empty-trie/compressed-trie cases, and also verifies the resulting proofs verify successfully via VerifyProof. Existing invariant/error tests (TestProveErrsOnStaleHashes, TestProveSetInvariant, etc.) were extended to cover ProveMulti too.

No blocker or important issues found. No inline comments posted — the code is correct as far as I can trace it, and well covered by the added equivalence tests.

Nit: core/trie/proof.go's proveMultiFrom and core/trie2/proof.go's proveMultiFrom are structurally similar but not shared — understandable given the two trie implementations diverge elsewhere too, so not suggesting a refactor here.

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.33094% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.29%. Comparing base (810f21a) to head (e4522a0).
⚠️ Report is 28 commits behind head on main.

Files with missing lines Patch % Lines
core/trie2/proof.go 88.63% 10 Missing ⚠️
rpc/v10/storage.go 73.68% 10 Missing ⚠️
rpc/v9/storage.go 73.68% 10 Missing ⚠️
core/trie/proof.go 92.72% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4015      +/-   ##
==========================================
- Coverage   79.56%   79.29%   -0.28%     
==========================================
  Files         461      464       +3     
  Lines       35513    35994     +481     
==========================================
+ Hits        28255    28540     +285     
- Misses       7249     7445     +196     
  Partials        9        9              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rodrodros
rodrodros self-requested a review September 1, 2026 15:57
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cicr99's task in 2m 25s —— View job


Review: PR #4015 — optimize storage proof generation with multi-key trie proofs

  • Gather context (diff, changed files)
  • Review core/trie/proof.go and core/trie2/proof.go (multi-key proof logic)
  • Review core/state/state_reader.go changes
  • Review rpc/v8, v9, v10 storage.go handler changes
  • Review tests for coverage/meaningfulness
  • Post final summary

Summary

This PR was already reviewed in depth after commit 982206d (see the review above, which traced the proveMultiFrom algorithm in both core/trie and core/trie2, the RPC handler reordering, and buildContractLeavesDataFromMetadata, finding no blockers). The new commit since then (e1ced3bc9, "refactor: fix linting errors") is purely mechanical plus one genuine test fix, so I focused this pass there:

  • core/trie/proof.go: proveMultiFrom was split into shouldSkipMultiProofNode, proofNode, multiProofKeysForNode, splitKeysByBit, readKnownProofChildren/readKnownProofChild purely to satisfy line-length/complexity lint rules. I diffed the extracted logic against the previous inline version line-by-line — the nil/empty-key/childKey.len == 0 guards and the left/right split-then-recurse order are preserved exactly, no behavior change.
  • core/trie2/proof.go: same kind of extraction (nonEmptyPaths, resolveProofNode, proveMultiFromEdge/proveMultiFromBinary, matchingEdgePaths, splitPathsByMSB) — again a faithful refactor of the previously-reviewed logic, no semantic delta.
  • core/state/state_reader.go: only a signature line-wrap for ContractMetadata; the added assertions in state_reader_test.go (checking zero-value returns on ErrKeyNotFound) are a nice small hardening of the existing test.
  • rpc/v10/storage_test.go: the benchmark helpers were restructured into a table-driven fixture (no behavioral change to the handler under test), and in the process a real bug in the benchmark setup was fixed — the class trie was being built/opened with crypto.Pedersen instead of crypto.Poseidon (Starknet's class trie uses Poseidon). This only affected benchmark fixture correctness, not production code.

I also re-verified the parts most relevant to protocol correctness given the "combine contract metadata reads" change: stateObject.commit() (core/state/object.go:92-94) sets contract.StorageRoot from the freshly committed trie root before persisting, so the on-disk stateContract.StorageRoot read via ContractMetadata is guaranteed consistent with ContractStorageTrie(addr).Hash() at that state root — the metadata fast-path in buildContractLeavesDataFromMetadata (rpc/v10/storage.go:456) can't return a stale root.

No blocker or important issues found in this delta. No inline comments posted.

Nit: Patch coverage is 84% per Codecov, with the remaining gaps concentrated in error-handling branches of rpc/v{8,9,10}/storage.go (~10 lines each) and the two proof.go files (~7-9 lines each) — likely the default: unknown trie type / internal-error branches, which are hard to hit without a synthetic reader. Not blocking, but worth a look if easy to add.
· branch: perf/rpc-get-storage-proof

Comment thread rpc/v10/storage_test.go Outdated
Comment thread rpc/v8/storage.go
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cicr99's task in 1m 35s —— View job


Review: PR #4015 — optimize storage proof generation with multi-key trie proofs

  • Gather context (diff, changed files, latest commits)
  • Check whether rodrodros's two review comments were addressed
  • Review updated core/trie/proof.go / core/trie2/proof.go
  • Review rpc/v9, rpc/v10 storage handler changes
  • Review new rpc/v10/storage_bench_test.go
  • Post final summary

Summary

This PR's core algorithm (proveMultiFrom in both core/trie and core/trie2) was already traced in depth in the two prior review passes on this thread, with no blockers found. This pass focuses on the latest commit (e4522a03f, "refine storage proof benchmarks and skip v8 changes"), which responds directly to @rodrodros's two review comments:

  • "RPC v8 is very close to being deleted, revert the changes for this version" — confirmed done. git diff origin/main...HEAD -- rpc/v8/ is now empty; rpc/v8/storage.go no longer appears in the PR's changed files at all, and getClassProof/getContractProof*/getContractStorageProof in v8 are back to the original single-key Prove loops.
  • "Benchmark should have a dedicated storage_bench_test.go" — confirmed done. The benchmark table (BenchmarkStorageProof and its fixture/helpers) moved out of rpc/v10/storage_test.go into a new rpc/v10/storage_bench_test.go, matching the existing repo convention (rpc/v10/addtxn_bench_test.go, core/aggregated_bloom_filter_bench_test.go, etc. already use this _bench_test.go split). storage_test.go now only contains correctness tests, as requested.

While re-reviewing the refactored benchmark file, I also verified:

  • benchmarkStorageProofTries/benchmarkTrie correctly use crypto.Poseidon for the class trie and crypto.Pedersen for contract/storage tries — the Pedersen/Poseidon mixup flagged in the prior review round has stayed fixed.
  • rpc/v9/storage.go and rpc/v10/storage.go remain structurally identical (the only difference is v9 fetching BlockHeaderByNumber vs v10's BlockHeaderHashByNumber), so the ProveMulti wiring and validation-reordering logic didn't drift between the two versions in this update.

No blocker or important issues found in this delta. No inline comments posted — this commit is a clean, faithful response to the two outstanding review comments with no new logic to flag.
· branch: perf/rpc-get-storage-proof

@brbrr brbrr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks solid 👍

Comment thread rpc/v10/storage.go
Comment thread rpc/v10/storage.go
Comment thread rpc/v10/storage.go
func buildContractLeavesDataFromMetadata(
state contractMetadataReader,
contracts []felt.Felt,
) ([]*LeafData, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't understand why we return a slice of pointers. Why not just returning []LeafData?

[]LeafData is just 1 heap allocation
[]*LeafData is n + 1 allocations: 1 from the slice, n from all the elements that also need to be allocated in the heap.

Comment thread rpc/v10/storage.go
Comment on lines +471 to +475
contractLeavesData[i] = &LeafData{
Nonce: &nonce,
ClassHash: &classHash,
StorageRoot: &storageRoot,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adding on this, every LeafData is also responsible of 3 other heap allocations.

The reason this happens is because you're storing pointers instead of the actual data. The Go Runtime guarantees these pointers to always be valid by allocating these types in the heap.

More allocation in the heap doesn't only translate as more memory taken (not a big deal) but also, as more CPU time being taken (big deal) from the program itself and given to the Garbage Collection.

Think also, we are allocating in the heap a lot of individual data that it is meant to be short lived. This data is only being brought to memory to answer an RPC request and after that is no longer needed.

Hence it is ok to allocate data in the heap, but in this case, so many small allocations which would normally be harmless do make a difference when we are optimizing for micro-seconds

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All this can be made in a follow up, Juno has this bad practice every way around and it is a conscious effort to not replicate them.

Because they are everywhere, one simple fix here, becomes a hundred of lines refactor, just to avoid the RPC heap allocation.

@rodrodros rodrodros Sep 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As it stands: the whole allocation cost of contractLeavesData is n + 3n + 1.

  • If we change the type to []LeafData then is 3n + 1
  • If change the internal fields of LeafData to value types then is 1 (Ideally this one)

Comment thread rpc/v10/storage.go
Comment on lines +452 to +454
type contractMetadataReader interface {
ContractMetadata(addr *felt.Felt) (classHash, nonce, storageRoot felt.Felt, err error)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't feel confortable creating a new type which is only going to be used in one single place. Is there a better way, where we avoid this?

Maybe we can add ContractMetadata to the StateReader interface? What do you think?

Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
Comment thread core/trie/proof.go
knownChildren = append(knownChildren, rightChild)
}

return knownChildren, leftNode, rightNode, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not return leftStorageNode and rightStorageNode alongside with leftNode and rigthNode?

I believe the only downside would be manually handling them in addProofNode?

Comment thread core/trie/proof.go
Comment on lines +273 to +286
func (t *Trie) readKnownProofChild(
keys []BitArray,
childKey *BitArray,
) (*Node, *StorageNode, error) {
if len(keys) == 0 || childKey == nil || childKey.len == 0 {
return nil, nil, nil
}

node, err := t.readStorage.Get(childKey)
if err != nil {
return nil, nil, err
}

return node, &StorageNode{key: childKey, node: node}, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not sure if it would fit here, I believe we could return StorageNode by value. What do you think? Is returning it as nil important? If it is, we can keep it as a reference 👍

Comment thread core/trie/proof.go
Comment on lines +311 to +322
binary, err := binaryProofNode(t, sNode, knownChildren...)
if err != nil {
return nil, err
}

if edge != nil { // Internal Edge
proof.Put(edgeHash(edge, carriedHash, t.hash), edge)
}
proof.Put(*sNode.node.Value, binary)

return binary, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There is no reason for this function to return *Binary, when it can easily return Binary and spare the allocation. Note that binaryProofNode, which should also be named createBinaryProofNode, creates the type and returns a reference to it which doesn't make sense. If a function/method create a value inside it, it should return it as a value type most of the time.

@brbrr did you write the function to return by pointer intentionally, or was it a miss?

Please add this to the refactor list alongside the LeafData type and array mentioned in a previous comment 🙏

Comment thread core/trie/proof.go
@rodrodros

rodrodros commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

The core of the PR is good but there are many little things missing. I will check trie2 implementation later today / tomorrow.

Comment thread core/trie2/proof.go

continuingPaths := nonEmptyPaths(paths)
if len(continuingPaths) == 0 {
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe there should be a test, where this condition becomes the stop case for recursion

@rodrodros

Copy link
Copy Markdown
Contributor

@cicr99 since this is a perf PR can you add benchmark results to the PR description 🙏 (using k9).

I am aware you already got the numbers. I believe it is good putting them here for posterity

@rodrodros

Copy link
Copy Markdown
Contributor

@cicr99 since ProveMulti is a generalization over Prove, I see no point in keeping both implementations. Why not replace Prove altogether with ProveMulti (and hence call the latter Prove). It can be on a separate PR

@rodrodros
rodrodros merged commit 113e296 into main Sep 10, 2026
29 of 30 checks passed
@rodrodros
rodrodros deleted the perf/rpc-get-storage-proof branch September 10, 2026 12:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants