Skip to content

fix(precompile-storage): clear stale dynamic tail chunks after Cobalt#3987

Open
arjun-dureja wants to merge 5 commits into
mainfrom
arjundureja/bop-339-24-dynamic-storage-shrink-overwrites-leave-stale-tail-slots
Open

fix(precompile-storage): clear stale dynamic tail chunks after Cobalt#3987
arjun-dureja wants to merge 5 commits into
mainfrom
arjundureja/bop-339-24-dynamic-storage-shrink-overwrites-leave-stale-tail-slots

Conversation

@arjun-dureja

@arjun-dureja arjun-dureja commented Jul 16, 2026

Copy link
Copy Markdown

Summary

  • clear retired backing slots when shorter Bytes, String, or Vec<T> values overwrite longer values
  • preserve legacy storage and gas behavior before Cobalt
  • route zero writes through normal SSTORE accounting so EIP-2200/EIP-3529 refunds propagate

Implementation

  • add typed StorageSemantics::{Legacy, Cobalt} selected by BasePrecompiles
  • thread the selected semantics through direct and dynamically looked-up B-20 precompiles
  • clear long bytes/string chunks before writing new metadata
  • clear fully retired packed vector slots and recursively delete retired unpacked elements
  • preserve handler-based vector pushes so reused pre-Cobalt dynamic slots are cleaned correctly
  • reject static writes before preparatory cleanup SLOADs

Scope

This prevents new stale tails at and after Cobalt and cleans legacy tails when their metadata remains reachable. Historical orphaned slots whose old length metadata was already overwritten cannot be discovered without a separate state migration or high-water metadata.

Testing

  • cargo +nightly fmt --all -- --check
  • cargo test -p base-precompile-storage --all-features
  • cargo test -p base-common-precompiles
  • cargo test -p base-common-evm
  • cargo check -p base-precompile-storage --no-default-features
  • RISC0_SKIP_BUILD_KERNELS=1 BASE_SUCCINCT_ELF_STUB=1 cargo clippy -p base-precompile-storage -p base-common-precompiles -p base-common-evm --all-targets -- -D warnings

References

@linear

linear Bot commented Jul 16, 2026

Copy link
Copy Markdown

BOP-339

@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

@arjun-dureja
arjun-dureja force-pushed the arjundureja/bop-339-24-dynamic-storage-shrink-overwrites-leave-stale-tail-slots branch 2 times, most recently from c9b7b7f to c7259cc Compare July 17, 2026 17:41
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
@arjun-dureja
arjun-dureja force-pushed the arjundureja/bop-339-24-dynamic-storage-shrink-overwrites-leave-stale-tail-slots branch from c7259cc to 588372f Compare July 17, 2026 17:46
Comment thread crates/common/precompiles/src/activation/precompile.rs Outdated
@arjun-dureja arjun-dureja changed the title fix(precompile-storage): clear stale dynamic tails after Cobalt fix(precompile-storage): clear stale dynamic tail chunks after Cobalt Jul 17, 2026
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Comment thread crates/common/precompiles/src/b20_factory/precompile.rs Outdated
arjun-dureja and others added 2 commits July 17, 2026 15:06
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>

let $calldata: ::alloy_primitives::Bytes = input.data.to_vec().into();
let mut provider = ::base_precompile_storage::EvmPrecompileStorageProvider::new(
let mut provider = ::base_precompile_storage::EvmPrecompileStorageProvider::new_with_storage_semantics(

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.

The third macro arm (line 34, the |$input, $ctx, $calldata| variant) was not updated in this PR and still calls EvmPrecompileStorageProvider::new($input) — which hardcodes Legacy semantics and also passes only one argument when new requires two (input, gas_params). It would fail to compile if instantiated.

Since this PR adds storage_semantics support to the first two arms, consider either updating the third arm to match or removing it if it's dead code.

Comment on lines +507 to +515
if T::BYTES <= 16 {
let first_tail_slot = calc_packed_slot_count(new_len, T::BYTES);
let old_slot_count = calc_packed_slot_count(old_len, T::BYTES);
for slot_idx in first_tail_slot..old_slot_count {
let slot_addr = data_start
.checked_add(U256::from(slot_idx))
.ok_or(BasePrecompileError::SlotOverflow)?;
storage.store(slot_addr, U256::ZERO)?;
}

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.

For packed types, clear_stale_elements only zeros fully-retired slots (those with index >= calc_packed_slot_count(new_len)). It does not clear individual stale elements that share a "boundary" slot with kept elements.

This is correct today because store_packed_elements rebuilds each slot from PackedSlot(U256::ZERO), so any stale bytes in the boundary slot are overwritten. However, this invariant is non-obvious and fragile — if someone later changes store_packed_elements to do read-modify-write (to avoid re-writing unchanged elements), stale bytes would leak.

Consider adding a comment documenting this dependency, e.g.:

// NOTE: Only fully-retired slots are cleared here. Stale bytes in the
// boundary slot (the last slot occupied by the *new* array) are handled
// by `store_packed_elements`, which rebuilds each slot from zero.

Comment on lines +137 to +140
/// Checks whether writes are allowed before any preparatory reads occur.
fn ensure_writable(&self) -> Result<()> {
Ok(())
}

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.

The default ensure_writable implementation returns Ok(()) unconditionally, meaning any StorageOps implementor that forgets to override it will silently permit writes in static contexts when Cobalt cleanup runs.

TransientOps (in slot.rs) inherits this default, which is fine since TSTORE in a static context is caught at the EVM level. But if a new StorageOps impl is added for persistent storage and forgets to override ensure_writable, cleanup SLOADs would execute in static calls before the eventual SSTORE catches the violation — subtly violating the "reject before preparatory reads" contract that the test static_write_fails_before_cobalt_cleanup_load enforces.

Consider making ensure_writable a required method (no default) so new implementors are forced to handle it, or at minimum add a doc comment warning that persistent-storage implementations must override this.

@github-actions

Copy link
Copy Markdown
Contributor

Review Summary

This PR adds fork-dependent storage cleanup semantics (StorageSemantics::Legacy / Cobalt) that clear stale tail slots when dynamic values (Bytes, String, Vec<T>) shrink. The core cleanup logic in bytes_like.rs and vec.rs is correct — old tail chunks are cleared before new data is written, the ordering is sound for EIP-2200/3529 gas accounting, and static-call violations are properly caught before any preparatory SLOADs.

Test coverage is thorough — tests cover long→short, long→shorter-long, packed/unpacked vector shrink, boundary slot behavior, nested dynamic struct cleanup, push-after-shrink, malformed length rejection, and static-call rejection ordering.

Findings

provider.rsStorageOps::ensure_writable default is permissive (low severity)

The default ensure_writable implementation returns Ok(()). This is safe for TransientOps (TSTORE violations caught at the EVM level) but could silently allow cleanup SLOADs in static contexts for future persistent-storage StorageOps implementors that forget to override it. Consider making it a required method or documenting the override requirement.

vec.rs — Packed cleanup relies on undocumented store_packed_elements invariant (nit)

clear_stale_elements for packed types only zeros fully-retired slots, relying on store_packed_elements (which rebuilds from PackedSlot(U256::ZERO)) to handle stale bytes in boundary slots. This is correct but fragile — a future optimization to read-modify-write would break it. A comment documenting this dependency would help.

Previously raised (from earlier review)

  • activation/precompile.rsActivationRegistry derives semantics from admin_config.state_enabled rather than BaseUpgrade, diverging from all other precompiles
  • b20_factory/precompile.rs — The if upgrade >= Cobalt { Cobalt } else { Legacy } conversion is duplicated ~8 times; a centralized StorageSemantics::from_upgrade constructor would reduce divergence risk
  • macros.rs — Third macro arm (|$input, $ctx, $calldata|) was not updated and calls EvmPrecompileStorageProvider::new($input) with wrong arity

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.

2 participants