-
Notifications
You must be signed in to change notification settings - Fork 206
fix(l1): charge snap serving budget on every lookup attempt #6957
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
ff3f41d
fcb4345
169b10a
368182a
352970d
1119c5e
0b61b45
21b89ee
24ccc14
1840948
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,16 @@ use super::constants::MAX_RESPONSE_BYTES; | |
| use super::error::SnapError; | ||
| use super::proof_to_encodable; | ||
|
|
||
| /// Minimum budget charged per lookup *attempt* in the snap serving handlers, independent of | ||
| /// whether the lookup hit. The response-byte clamp only advances on a hit, so without this a | ||
| /// miss/empty/overlong-path request costs nothing and the handler walks the entire | ||
| /// peer-supplied list (`hashes` / `account_hashes` / `paths`) — O(N) DB/cache probes with a | ||
| /// near-empty response, which the message-rate limiter (counting messages, not items) doesn't | ||
| /// catch. Charging a per-attempt minimum bounds that walk against the same byte budget. | ||
| /// Sized at a hash's wire length; with the 512 KiB response cap this bounds an all-miss walk | ||
| /// to ~16K probes. | ||
| const MIN_LOOKUP_COST: u64 = 32; | ||
|
|
||
| // Request Processing | ||
|
|
||
| pub async fn process_account_range_request( | ||
|
|
@@ -95,7 +105,13 @@ pub async fn process_storage_ranges_request( | |
| )); | ||
| } | ||
|
|
||
| if !account_slots.is_empty() { | ||
| if account_slots.is_empty() { | ||
| // Absent account or empty range returns no slots; charge a per-probe minimum | ||
| // so an all-miss `account_hashes` list trips the budget instead of opening the | ||
| // storage trie once per entry. Hit accounts already paid 64 B/slot above, so | ||
| // they are not charged again (avoids over-debiting non-empty responses). | ||
| bytes_used += MIN_LOOKUP_COST; | ||
| } else { | ||
| slots.push(account_slots); | ||
| } | ||
|
|
||
|
|
@@ -125,9 +141,18 @@ pub async fn process_byte_codes_request( | |
| let mut codes = vec![]; | ||
| let mut bytes_used = 0; | ||
| for code_hash in request.hashes { | ||
| if let Some(code) = store.get_account_code(code_hash)?.map(|c| c.code_bytes()) { | ||
| bytes_used += code.len() as u64; | ||
| codes.push(code); | ||
| match store.get_account_code(code_hash)? { | ||
| Some(code) => { | ||
| let code = code.code_bytes(); | ||
| // Charge at least `MIN_LOOKUP_COST` even for a hit, so a long list of | ||
| // duplicate hashes of a *tiny* code is bounded by probe count, not just by | ||
| // response bytes. | ||
| bytes_used += (code.len() as u64).max(MIN_LOOKUP_COST); | ||
| codes.push(code); | ||
| } | ||
| // A missed lookup still costs a probe; charge it so an all-miss request trips | ||
| // the budget instead of walking the entire `hashes` list. | ||
| None => bytes_used += MIN_LOOKUP_COST, | ||
| } | ||
| if bytes_used >= byte_budget { | ||
| break; | ||
|
|
@@ -162,9 +187,14 @@ pub async fn process_trie_nodes_request( | |
| paths.into_iter().map(|bytes| bytes.to_vec()).collect(), | ||
| byte_budget, | ||
| )?; | ||
| let returned_bytes = trie_nodes | ||
| .iter() | ||
| .fold(0u64, |acc, nodes| acc + nodes.len() as u64); | ||
| nodes.extend(trie_nodes.iter().map(|nodes| Bytes::copy_from_slice(nodes))); | ||
| byte_budget = byte_budget | ||
| .saturating_sub(trie_nodes.iter().fold(0, |acc, nodes| acc + nodes.len()) as u64); | ||
| // Charge at least a per-pathset probe cost so overlong/empty-result pathsets | ||
| // (e.g. a path > 32 bytes, which resolves to an empty node) still consume the | ||
| // budget instead of letting the loop run over every decoded pathset. | ||
| byte_budget = byte_budget.saturating_sub(returned_bytes.max(MIN_LOOKUP_COST)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cost should be 32 per path, not total. Otherwise the user can request as many paths as the protocol supports and if they all miss, only pay once.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 21b89ee: it now charges
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Superseded by the count-cap rework (24ccc14): |
||
| if byte_budget == 0 { | ||
| break; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MAX_RESPONSE_BYTES/MIN_LOOKUP_COST is currently 16k, plus each trie lookup requiring several lookups (since it has to traverse the trie), makes the worst case very resource intensive
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With per-path charging (above), total probed paths per request is now bounded by budget/32 — ~16k for the 512 KiB cap. If you want that tighter given each trie lookup traverses several nodes, I can raise the per-lookup cost for trie nodes above the flat 32 (they're pricier than a single bytecode/DB get), e.g. down to roughly geth's
maxTrieNodeLookups(~1024). Happy to — just say what bound you'd prefer.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reworked this to address the concern directly (24ccc14): instead of the byte-cost proxy, the handlers now enforce a hard per-request lookup-count cap,
MAX_SERVE_LOOKUPS= 1024, so the worst case is 1024 trie descents per request, not ~16k.I took geth as a reference point here: it caps
GetTrieNodes/GetByteCodesatmaxTrieNodeLookups/maxCodeLookups= 1024 each (plussoftResponseLimit2 MB and amaxTrieNodeTimeSpent5 s budget). I used a single shared 1024 cap rather than geth's per-message split since it serves the same purpose and is simpler. 1024 sits above our own client's request batches (NODE_BATCH_SIZE500,STORAGE_BATCH_SIZE300), and snap responses may be partial, so honest peers just re-request the remainder.Since the count cap is a tighter, more direct bound on the same walk, I dropped
MIN_LOOKUP_COST(and its store-level twinMIN_TRIE_NODE_LOOKUP_COST) entirely.