Skip to content

fix(storage): cleanup tail in dyn types#3840

Open
0xrusowsky wants to merge 3 commits intomainfrom
rus/clear-dyn-writes
Open

fix(storage): cleanup tail in dyn types#3840
0xrusowsky wants to merge 3 commits intomainfrom
rus/clear-dyn-writes

Conversation

@0xrusowsky
Copy link
Copy Markdown
Contributor

Motivation

Overwriting a dynamic storable (Vec<T>, String, Bytes) with a shorter value left stale tail slots populated, so subsequent reads observed garbage past the new length.

Solution

Ensure that "shrinking" writes on dyn types clear their stale tails.

Additionally, introduces a new sentinel LayoutCtx::INIT for hot paths that know the destination is virgin (Vec::push), letting them skip the extra SLOAD + cleanup.

@0xrusowsky 0xrusowsky added the cyclops Trigger Cyclops PR audit label May 6, 2026
@0xrusowsky 0xrusowsky force-pushed the rus/clear-dyn-writes branch from 0b5e41b to 33254c0 Compare May 6, 2026 18:03
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

📊 Tempo Precompiles Coverage

precompiles

Coverage: 5647/7748 lines (72.88%)

File details
File Lines Coverage
src/account_keychain/dispatch.rs 30/68 44.12%
src/account_keychain/mod.rs 274/736 37.23%
src/address_registry/dispatch.rs 31/33 93.94%
src/address_registry/mod.rs 50/56 89.29%
src/error.rs 39/114 34.21%
src/ip_validation.rs 10/10 100.00%
src/lib.rs 180/216 83.33%
src/nonce/dispatch.rs 9/10 90.00%
src/nonce/mod.rs 46/61 75.41%
src/signature_verifier/dispatch.rs 19/20 95.00%
src/signature_verifier/mod.rs 13/17 76.47%
src/stablecoin_dex/dispatch.rs 92/93 98.92%
src/stablecoin_dex/mod.rs 876/918 95.42%
src/stablecoin_dex/order.rs 110/161 68.32%
src/stablecoin_dex/orderbook.rs 157/216 72.69%
src/storage/evm.rs 192/221 86.88%
src/storage/hashmap.rs 0/158 0.00%
src/storage/mod.rs 27/27 100.00%
src/storage/packing.rs 68/93 73.12%
src/storage/thread_local.rs 165/227 72.69%
src/storage/types/array.rs 0/72 0.00%
src/storage/types/bytes_like.rs 91/183 49.73%
src/storage/types/mapping.rs 27/48 56.25%
src/storage/types/mod.rs 70/97 72.16%
src/storage/types/primitives.rs 21/24 87.50%
src/storage/types/set.rs 28/192 14.58%
src/storage/types/slot.rs 55/81 67.90%
src/storage/types/vec.rs 102/260 39.23%
src/tip20/dispatch.rs 149/165 90.30%
src/tip20/mod.rs 590/693 85.14%
src/tip20/rewards.rs 238/252 94.44%
src/tip20/roles.rs 107/110 97.27%
src/tip20_factory/dispatch.rs 17/18 94.44%
src/tip20_factory/mod.rs 105/125 84.00%
src/tip403_registry/dispatch.rs 55/56 98.21%
src/tip403_registry/mod.rs 334/371 90.03%
src/tip_fee_manager/amm.rs 286/364 78.57%
src/tip_fee_manager/dispatch.rs 81/83 97.59%
src/tip_fee_manager/mod.rs 71/136 52.21%
src/validator_config/dispatch.rs 38/52 73.08%
src/validator_config/mod.rs 171/227 75.33%
src/validator_config_v2/dispatch.rs 71/73 97.26%
src/validator_config_v2/mod.rs 552/611 90.34%

contracts

Coverage: 1/253 lines (0.40%)

File details
File Lines Coverage
src/lib.rs 1/1 100.00%
src/precompiles/account_keychain.rs 0/40 0.00%
src/precompiles/address_registry.rs 0/12 0.00%
src/precompiles/nonce.rs 0/15 0.00%
src/precompiles/signature_verifier.rs 0/3 0.00%
src/precompiles/stablecoin_dex.rs 0/18 0.00%
src/precompiles/tip20.rs 0/61 0.00%
src/precompiles/tip20_factory.rs 0/9 0.00%
src/precompiles/tip403_registry.rs 0/24 0.00%
src/precompiles/tip_fee_manager.rs 0/18 0.00%
src/precompiles/validator_config.rs 0/13 0.00%
src/precompiles/validator_config_v2.rs 0/39 0.00%

Total: 5648/8001 lines (70.59%)

📦 Download full HTML report

@0xrusowsky 0xrusowsky marked this pull request as ready for review May 6, 2026 18:12
Comment on lines +305 to +310
// Handlers always use `FULL` ctx:
// `T::Handler::write(v)` → `self.as_slot().write(v)` → `Slot::<T>::new(s, a).write(v)`.
// Since the slot we push to is guaranteed empty, we build the `Slot<T>` directly to
// thread `INIT` into `T::store` and skip its tail-cleanup SLOAD for dynamic types.
let elem_slot = self.data_slot() + U256::from(length * T::SLOTS);
Slot::<T>::new_with_ctx(elem_slot, LayoutCtx::INIT, self.address).write(value)?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

NOTE: i believe this workaround is safe because of the reasons explained in the cmnt

in order to use Self::compute_handler with LayoutCtx::INIT we'd have to change all non-primitive handlers to use and hold ctx (which currently ignore)

@0xrusowsky 0xrusowsky added the S-breaking-stf This PR includes a breaking STF change label May 6, 2026
@jenpaff jenpaff added the T5 label May 6, 2026
Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

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

👁️ Cyclops Review

Four worker passes (w1×3, w2×2) returned NO_FINDING for any T2-active vulnerability. The PR is well-scoped and the new T5-gated cleanup branches plus LayoutCtx::INIT are deterministic, bounded, and consistent with the typed-storage invariants.

Head drift note: workers audited 0b5e41b40efb; this review targets bcc60e304e3fcdad7818f75618fe71019cde6104. The drift moved gating from T4 to T5 and fixed the previously-broken test compile (ONEU256::ONE in structs.rs:309), so that finding has been dropped as stale.

🛡️ [DEFENSE-IN-DEPTH] [T; N] Storable impl not migrated to is_full()

File: crates/precompiles-macros/src/storable_primitives.rs:378,387,399 (not in this PR's diff — body-only)

Sister types (Vec, String, Bytes, struct macro) were uniformly updated to debug_assert!(ctx.is_full(), …), but gen_array_impl's generated handle/load/store still use debug_assert_eq!(ctx, LayoutCtx::FULL, …). Today this is dormant — no production Vec<[T; N]> exists and the struct macro forces FULL on non-dynamic fields. But if anyone adds Vec<[u8; 32]> (or any [T; N] with T::BYTES * N > 16), Vec::push will thread INIT into [T; N]::store and the equality assert will fire in debug/test builds (release silently works because store_impl doesn't consult ctx).

Recommended Fix: Replace the three debug_assert_eq!(ctx, LayoutCtx::FULL, …) calls with debug_assert!(ctx.is_full(), …) for symmetry.

See inline comments for the other two items.

Reviewer Callouts
  • Gas-cost change for shrinking dynamic types under T5Vec::store and store_bytes_like add up to prev - new extra SSTOREs (or T::delete calls for unpacked) on T5. Gas-metered and not griefable, but per-operation costs of shrinking change post-T5. Worth confirming downstream precompile gas budgets (e.g., account_keychain revoke flows, stablecoin_dex book mutations, validator config rewrites with long string fields) accommodate the additional SSTOREs.

  • LayoutCtx::INIT "virgin by construction" for Vec::push is fork-history sensitive — Holds for any Vec whose entire lifetime is on T5 (every shrinking path zeros freed elements). Does NOT hold for a Vec whose history includes a pre-T5 shrink. Concretely, write(vec![long_a, long_b, long_c]) then write(vec![x, y]) under T2/T3/T4 leaves long_c's chunks live; a later T5 push("z") to index 2 writes the base slot with INIT but the legacy chunks at keccak256(data_start + 2) + i persist. Impact is purely storage bloat — every read path is length-bounded and keccak256 precludes collisions with live storage.

  • Pre-T5 → T5 activation hygiene — Combined with the bytes_like inline finding, a human reviewer should confirm the T5 activation plan accounts for legacy dynamic tails that may already exist when T5 activates. Cleanup only runs on subsequent writes/deletes; pre-existing ghost data in long validator config strings, TIP-20 metadata, etc. is never proactively scrubbed.

  • Mapping::IS_DYNAMIC defaults to false (storage/types/mod.rs:182) — Mapping is keccak-addressed but does not implement Storable (only StorableType), so the derive splits mapping fields out before calling store/load/delete. Harmless today, but if a future change adds a Storable impl to Mapping, the propagation logic in gen_store_impl would need updating.

let mut data_slot: Option<U256> = None;

// (T5+) Cleanup stale tail if necessary.
if !ctx.skip_tail_cleanup() && StorageCtx.spec().is_t5() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ [DEFENSE-IN-DEPTH] store_bytes_like cleanup misses pre-T5 ghost chunks

This branch keys on whether the current base slot decodes as a long string (is_long_string(prev) below). If a string was long pre-T5, then overwritten short pre-T5 (so the base reads short but the keccak data slots still hold the original long-string bytes), then post-T5 overwritten with another long value of fewer chunks than the original, the cleanup sees is_long_string(prev) = false and skips clearing. The keccak slots beyond the new length retain ghost bytes from the original pre-T5 write.

Reads remain correct (length-bounded), so this is not a consensus or correctness issue. But the implicit "after T5, slots beyond the recorded length are zero" invariant does not hold across the activation boundary. The same caveat applies to Vec::store's use of prev_len from len_slot.

Recommended Fix:
Either (a) document explicitly in the doc-comment above that the guarantee is "operations-from-T5-onward" rather than absolute, so future hardforks that consume keccak-derived data slots don't read ghost bytes; or (b) ship a one-shot T5-activation migration to scrub legacy tails. Option (a) is sufficient unless a future fork plans to reuse those slots.


// (T5+) Cleanup stale tail, if necessary.
if !ctx.skip_tail_cleanup() && StorageCtx.spec().is_t5() {
let (prev_len, new_len) = (load_checked_len(storage, len_slot)?, self.len());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 [SUGGESTION] New T5 failure mode if len_slot was repurposed pre-T5

On T5, Vec::store now reads prev_len via load_checked_len, which errors if the raw value > u32::MAX. If a slot at len_slot was ever populated with a value > u32::MAX by something other than a Vec (e.g., schema reassignment / cross-type slot reuse), a Vec write to that slot will start failing on T5 where it previously succeeded pre-T5. No production code does this today, but worth checking before T5 activation that no precompile upgrade re-purposes a Slot<U256> (or similar) as a Slot<Vec<…>>.

Recommended Fix: Audit precompile storage layouts for cross-type slot reuse before T5 activation. No code change required if none exists.

decofe pushed a commit that referenced this pull request May 6, 2026
Collects bug fixes and infrastructure changes gated behind T5:
1. Fix fixed-size array packing in precompile storage codegen (#3811)
2. Clean up stale tail slots in dynamic storage types (#3840)
3. Deploy ERC-2470 and NanoUniversalDeployer at T5 boundary (#3742)

Standalone feature TIPs (1026, 1030, 1033, 1035, 1047, 1056) are
excluded — they already have their own specs.

Amp-Thread-ID: https://ampcode.com/threads/T-019dfe69-9662-77ff-9ff0-390655ec07ff
decofe pushed a commit that referenced this pull request May 6, 2026
Collects bug fixes gated behind T5:
1. Fix fixed-size array packing in precompile storage codegen (#3811)
2. Clean up stale tail slots in dynamic storage types (#3840)

Standalone feature TIPs (1026, 1030, 1033, 1035, 1047, 1056) are
excluded — they already have their own specs.

Amp-Thread-ID: https://ampcode.com/threads/T-019dfe69-9662-77ff-9ff0-390655ec07ff
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cyclops Trigger Cyclops PR audit S-breaking-stf This PR includes a breaking STF change T5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants