Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
5 findings
3 fixed
2 acknowledged
Low Risk
13 findings
3 fixed
10 acknowledged
Informational
20 findings
12 fixed
8 acknowledged
Medium Risk5 findings
Native SSTORE path omits EIP-2200 gas-stipend guard
Summary
Base native precompile storage writes do not enforce EIP-2200's
gasleft <= 2300SSTORE guard before mutating storage. As a result, low-gas direct calls to stateful native precompiles can perform warm dirty storage writes that the normal EVMSSTOREopcode would halt before executing.Finding Description
EIP-2200 requires an
SSTOREto fail the current call frame with out-of-gas when the frame's remaining gas is less than or equal to the call stipend. The pinnedrevmopcode implementation applies this sentry before host storage mutation: it checksgas.remaining() <= call_stipend()and halts withReentrancySentryOOG;CALL_STIPENDis 2300 gas in the same dependency set.The native precompile wrapper does not mirror that opcode-level guard. The stateful wrapper only rejects non-direct calls, copies calldata, constructs
EvmPrecompileStorageProvider, and entersStorageCtx:if !input.is_direct_call() { return ::base_precompile_storage::BasePrecompileError::revert( ::base_precompile_storage::DelegateCallNotAllowed {}, ) .into_precompile_result(0, 0);} let mut provider = ::base_precompile_storage::EvmPrecompileStorageProvider::new( input, ::revm::context_interface::cfg::GasParams::default(),);crates/common/precompiles/src/macros.rs#L6-L23ABI dispatchers then deduct calldata gas and forward into the precompile logic without checking the stipend boundary, for example B20 asset dispatch at
crates/common/precompiles/src/b20_asset/dispatch.rs#L22-L52, policy dispatch atcrates/common/precompiles/src/policy/dispatch.rs#L18-L27, activation dispatch atcrates/common/precompiles/src/activation/dispatch.rs#L16-L27, and factory dispatch atcrates/common/precompiles/src/b20_factory/dispatch.rs#L15-L19.The actual native storage provider only blocks static context, calls
internals.sstore(address, key, value), and charges SSTORE gas afterward:fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { if self.is_static { return Err(BasePrecompileError::StaticCallViolation); } let s = self .internals .sstore(address, key, value) .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; self.deduct_gas(self.gas_params.sstore_static_gas())?; self.deduct_gas(self.gas_params.sstore_dynamic_gas(true, &s.data, s.is_cold))?; self.refund_gas(self.gas_params.sstore_refund(true, &s.data)); Ok(())}crates/common/precompile-storage/src/evm.rs#L172-L189This is reachable through normal direct precompile calls. For example, B20
approvetakesctx.caller()as the allowance owner and callsself.approve(caller, c.spender, c.amount)atcrates/common/precompiles/src/b20_asset/dispatch.rs#L191-L194.approvethen writes the allowance and emitsApprovalatcrates/common/precompiles/src/common/ops/transferable.rs#L109-L120, and the allowance field is a direct mutable mapping atcrates/common/precompiles/src/common/core_storage.rs#L33-L36.A transaction that first warms and dirties the same allowance slot can later make a low-gas direct call into
approve. If the remaining gas is at or below 2300 but still enough for the warm dirty rewrite and subsequent event accounting, the native precompile path can mutate storage where the canonical opcode path would have halted before the write.Impact Explanation
Impact is Medium. The issue breaks an EVM semantic guarantee that contracts rely on as a reentrancy sentry: stipend-level calls should not be able to perform
SSTORE. The affected path is limited to native precompile storage, so this is not arbitrary EVM storage mutation, but it can still allow state changes in Base native precompiles under gas conditions that EIP-2200 intentionally forbids.Likelihood Explanation
Likelihood is Medium. Attackers need a direct call into a stateful native precompile with a target slot already warm and dirty in the same transaction, because cold or expensive writes are expected to run out of gas after the mutation attempt. Those preconditions are realistic for allowance-style B20 flows and other repeated writes to the same precompile slot, and the outer wrapper does not otherwise reject low-gas direct calls.
Proof of Concept
Recommendation
We recommend enforcing the EIP-2200 stipend sentry before every native precompile
sstoremutation.EvmPrecompileStorageProvider::sstoreshould check the current frame's remaining gas beforeinternals.sstoreand return an out-of-gas halt when remaining gas is less than or equal togas_params.call_stipend(). Add regression coverage for a warm dirty B20 allowance write where a normalSSTOREwould halt at the stipend boundary.Generic precompile success conversion drops accumulated SSTORE refunds
Summary
Successful native precompile calls that mutate storage through the generic dispatch result path can drop accumulated EIP-3529 SSTORE refunds, causing callers to overpay gas for otherwise valid refund-producing operations.
Finding Description
The storage context already has a refund-preserving success constructor:
storage_ctx.rsbuilds aPrecompileOutputand then copiesself.gas_refunded()intoout.gas_refunded. The production EVM storage provider also records refunds for refundableSSTOREs:evm.rscharges storage write gas and callsrefund_gas(...), whileevm.rsrecords and exposes the accumulated refund.However, the generic
IntoPrecompileResultsuccess conversion does not preserve that field:impl<T> IntoPrecompileResult<T> for Result<T> { fn into_precompile_result( self, gas: u64, state_gas: u64, encode_ok: impl FnOnce(T) -> Bytes, ) -> PrecompileResult { match self { Ok(res) => Ok(PrecompileOutput::new(gas, encode_ok(res), state_gas)), Err(err) => err.into_precompile_result(gas, state_gas), } }}This means any accumulated
ctx.gas_refunded()is lost when a mutating precompile returns through the generic success arm aterror.rs.This is reachable in current production dispatchers, not just a future API hazard. Affected dispatchers include:
PolicyRegistryStorage::dispatch, which executes write calls and then returns throughresult.into_precompile_result(...)atdispatch.rsActivationRegistryStorage::dispatchatdispatch.rsB20FactoryStorage::dispatchatdispatch.rsB20AssetStorage::dispatch_with_observeratdispatch.rsB20StablecoinStorage::dispatch_with_observeratdispatch.rs
Several normal successful operations can clear previously nonzero storage before reaching those generic success returns. Examples include:
- Clearing a pending policy admin in
storage.rs - Deleting the pending admin during finalization or renounce at
storage.rs - Deactivating a feature at
storage.rs - Zeroing balances through B20 transfers at
transferable.rs - Zeroing allowances through B20 approvals at
transferable.rs - Full-balance or full-supply burns at
burnable.rs
The broken guarantee is that gas refunds accumulated by the storage provider should be propagated into the precompile output so revm can apply them to transaction-level refund accounting under the EIP-3529 cap. Instead, these successful paths can return
gas_refunded == 0even after the provider recorded a valid refund.Recommendation
We recommend making the generic success path preserve accumulated refunds. One approach is to replace dispatcher success returns with a
StorageCtx-aware constructor equivalent toStorageCtx::success_output, or extendIntoPrecompileResult::into_precompile_resultto accept and copy the current refund counter intoPrecompileOutput.gas_refunded. Add regression tests for at least one dispatcher call that clears a nonzero storage slot and assert that the returnedPrecompileOutput.gas_refundedmatches the refund accumulated by the provider.set_code bypasses STATICCALL immutability enforcement
Summary
set_codemutates account bytecode without checking whether the current precompile execution is static.Finding Description
The EVM storage provider rejects static execution for ordinary state-changing operations, but
set_codedoes not apply the same guard. The guarded sibling mutators are:In
evm.rs,set_codeperforms gas accounting and then callsinternals.set_code(...).The in-memory provider mirrors the same behavior:
hashmap.rsupdatescode_hashandcodewithout checkingis_static. Generated precompile initialization reaches this path throughlayout.rs, which installs sentinel bytecode throughself.storage.set_code(self.address, bytecode)?.This breaks the EVM guarantee that a
STATICCALLcontext cannot perform state-changing side effects. Any static-context path that reaches code installation can mutate account bytecode even though surrounding callers expect immutable execution.Relevant code:
fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> { let code_len = code.len(); self.deduct_gas(self.gas_params.code_deposit_cost(code_len))?; // no self.is_static check before mutation self.internals .set_code(address, code) .map_err(|e| BasePrecompileError::Fatal(e.to_string()))} fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { if self.is_static { return Err(BasePrecompileError::StaticCallViolation); } // ...}Recommendation
We recommend adding the same static-context guard before any gas charging or code mutation in both storage providers. The guard should match the existing behavior for:
sstoretstoreemit_event
if self.is_static { return Err(BasePrecompileError::StaticCallViolation);}Dynamic storage shrink-overwrites leave stale tail slots
State
- Acknowledged
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Dynamic storage shrink-overwrites leave stale tail slots
Summary
Dynamic storage writers overwrite only the new logical value and do not clear storage slots that belonged to a previous longer
Bytes,String, orVec<T>value.Finding Description
Dynamic bytes-like values and vectors use a length field to bound normal typed reads, but the storage writers do not clear retired backing slots when a shorter value replaces a longer one. As a result, obsolete chunks or elements remain in raw storage after the overwrite.
This same issue was already fixed upstream in Tempo in PR #3840,
fix(storage): cleanup tail in dyn types, which was merged on May 7, 2026. The Tempo PR describes the same root cause: overwriting a dynamic storable (Vec<T>,String, orBytes) with a shorter value left stale tail slots populated, and the fix clears stale tails on shrinking writes.For
BytesandString,store_bytes_like()writes the new base slot and only the chunks required by the new byte length. It never loads the previous encoded length and therefore cannot clear old chunks beyond the new payload.fn store_bytes_like<S: StorageOps>(bytes: &[u8], storage: &mut S, base_slot: U256) -> Result<()> { let length = bytes.len(); if length <= 31 { storage.store(base_slot, encode_short_string(bytes)) } else { storage.store(base_slot, encode_long_string_length(length))?; let slot_start = calc_data_slot(base_slot); let chunks = calc_chunks(length); for i in 0..chunks { let slot = slot_start + U256::from(i); /* writes only the new chunks */ storage.store(slot, U256::from_be_bytes(chunk_bytes))?; } Ok(()) }}The delete path shows that these long-form chunks are part of the value footprint and are expected to be zeroed when removed:
fn delete_bytes_like<S: StorageOps>(storage: &mut S, base_slot: U256) -> Result<()> { let base_value = storage.load(base_slot)?; let is_long = is_long_string(base_value); if is_long { let length = calc_string_length(base_value, true)?; let slot_start = calc_data_slot(base_slot); let chunks = calc_chunks(length); for i in 0..chunks { storage.store(slot_start + U256::from(i), U256::ZERO)?; } } storage.store(base_slot, U256::ZERO)}The same pattern exists for vectors.
Vec<T>::store()updates the length and writes only the new prefix. Packed vectors write onlycalc_packed_slot_count(new_len)slots, and unpacked vectors iterate only the new elements.fn store<S: StorageOps>(&self, storage: &mut S, len_slot: U256, ctx: LayoutCtx) -> Result<()> { debug_assert_eq!(ctx, LayoutCtx::FULL, "Dynamic arrays cannot be packed"); storage.store(len_slot, U256::from(self.len()))?; if self.is_empty() { return Ok(()); } let data_start = calc_data_slot(len_slot); if T::BYTES <= 16 { store_packed_elements(self, storage, data_start, T::BYTES) } else { store_unpacked_elements(self, storage, data_start) }}By contrast,
Vec<T>::delete()loads the existing length and clears every old packed slot or unpacked element, confirming that ordinary overwrite skips cleanup that deletion performs.let length = load_checked_len(storage, len_slot)?;storage.store(len_slot, U256::ZERO)?; if T::BYTES <= 16 { let slot_count = calc_packed_slot_count(length, T::BYTES); for slot_idx in 0..slot_count { storage.store(data_start + U256::from(slot_idx), U256::ZERO)?; }} else { for elem_idx in 0..length { let elem_slot = data_start + U256::from(elem_idx * T::SLOTS); T::delete(storage, elem_slot, LayoutCtx::FULL)?; }}Typed reads are length-bounded, so the stale tail is masked when callers read the same type normally. However, the obsolete data remains visible through raw storage access and storage inspection, storage refunds for clearing removed values are missed, and future growth or manual slot use can encounter data that callers would reasonably expect to have been removed when assigning a shorter dynamic value.
Recommendation
We recommend making dynamic
store()clear any previously used tail when the new value is shorter than the old value. Bytes-like writers should load the prior encoded length before overwrite and zero old chunks beyond the new chunk count, including long-to-short transitions. Vector writers should load the prior length and clear the rangenew_len..old_len; packed vectors should zero retired packed slots, while unpacked vectors should callT::deletefor retired elements. If needed for efficiency, add an explicit zero-initialized storage context so first writes and appends can skip the old-length read safely.Storage layout hash derivation bypasses metered keccak accounting
State
- Acknowledged
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Summary
Runtime storage layout helpers compute mapping and dynamic-data slots with direct
keccak256calls instead of the gas-charging storage-context hash API. These hashes correspond to storage address derivations that Solidity/EVM bytecode would normally execute through theKECCAK256opcode, so precompile calls can force internal slot-derivation hashes without paying the corresponding runtime keccak gas.Finding Description
The storage provider exposes a metered hash primitive. The default provider implementation computes the EVM keccak price and deducts gas before hashing:
/// Computes keccak256 and charges the appropriate gas.fn keccak256(&mut self, data: &[u8]) -> Result<B256> { let num_words = u64::try_from(data.len().div_ceil(32)).map_err(|_| BasePrecompileError::OutOfGas)?; let price = KECCAK256WORD .checked_mul(num_words) .and_then(|w| w.checked_add(KECCAK256)) .ok_or(BasePrecompileError::OutOfGas)?; self.deduct_gas(price)?; Ok(keccak256(data))}However, the runtime storage-layout helpers bypass that metered API.
StorageKey::mapping_slotderives every mapping element slot by callingalloy_primitives::keccak256directly after building the 64-byte Solidity mapping preimage:fn mapping_slot(&self, slot: U256) -> U256 { let key_bytes = self.as_storage_bytes(); let key_bytes = key_bytes.as_ref(); debug_assert!(key_bytes.len() <= 32); let mut buf = [0u8; 64]; buf[32 - key_bytes.len()..32].copy_from_slice(key_bytes); buf[32..].copy_from_slice(&slot.to_be_bytes::<32>()); U256::from_be_bytes(keccak256(buf).0)}MappingHandlerreaches this helper whenever a key handler is materialized, including mutable access used by write paths:pub fn at_mut(&mut self, key: &K) -> &mut V::Handler<'a>where K: StorageKey + Eq + Clone + Ord,{ let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); self.cache.get_or_insert_mut(key, || { V::handle(key.mapping_slot(base_slot), LayoutCtx::FULL, address, storage) })}The same pattern exists for dynamic array data bases.
VecHandlercomputesself.data_slot()before pushing or indexing elements, butcalc_data_slotdirectly hashes the length slot and cannot charge gas because it has no storage context:let mut elem_slot = Self::compute_handler(self.data_slot(), self.address, self.storage, length);elem_slot.write(value)?;#[inline]pub(crate) fn calc_data_slot(len_slot: U256) -> U256 { U256::from_be_bytes(keccak256(len_slot.to_be_bytes::<32>()).0)}Bytes-like dynamic values and string mapping keys also use direct hashes for their long-data base or string-key mapping preimage:
fn mapping_slot(&self, slot: U256) -> U256 { let mut buf = Vec::with_capacity(self.len() + 32); buf.extend_from_slice(self.as_bytes()); buf.extend_from_slice(&slot.to_be_bytes::<32>()); U256::from_be_bytes(keccak256(buf).0)}fn calc_data_slot(base_slot: U256) -> U256 { U256::from_be_bytes(keccak256(base_slot.to_be_bytes::<32>()).0)}bytes_like.rsandbytes_like.rsThis is reachable from externally controlled precompile inputs. For example, policy membership updates accept an address array, cap it at 64 accounts, and then perform two nested mapping derivations for each account: one for
policy_idand one for the account under that policy mapping.Self::require_account_batch_size(accounts)?;for account in accounts { if add { self.members.at_mut(&policy_id).at_mut(account).write(true)?; } else { self.members.at_mut(&policy_id).at_mut(account).delete()?; }}The dispatch wrapper only charges a separate per-calldata-word fee, using a hardcoded
G_SHA3WORD = 6; it does not account for the internal mapping or dynamic-data slot hashes performed after ABI decoding:macro_rules! deduct_calldata_cost { ($ctx:expr, $calldata:expr $(,)?) => {{ const G_SHA3WORD: u64 = 6; let calldata_len = $calldata.len(); let calldata_cost = calldata_len.div_ceil(32).saturating_mul(G_SHA3WORD as usize) as u64; if let Err(e) = $ctx.deduct_gas(calldata_cost) { return e.into_precompile_result($ctx.gas_used(), $ctx.state_gas_used()); } }};}As a result, validators perform extra host keccak work that is absent from the reported gas usage. Storage access gas still applies to the eventual
SLOADorSSTORE, but these runtime layout hashes are separate computation and the code already contains a metered keccak primitive for that exact class of work. Calls that touch many distinct mapping keys or dynamic elements therefore undercharge CPU work relative to the number of keccak derivations they force.This issue is limited to runtime hashes that replace storage address derivations a Solidity contract would normally perform with the
KECCAK256opcode. Compile-time macro hashes, such as literal#[slot("...")]or namespace-root derivation performed during procedural macro expansion, are not part of this claim because they do not execute during a precompile call and would not be EVM-metered at runtime.Recommendation
We recommend routing storage-layout hash derivation through the metered provider path. For example, add context-aware helpers for mapping-slot and dynamic-data-slot derivation that call
StorageCtx::keccak256, then use those helpers fromMappingHandler,VecHandler, bytes/string storage, and any generated accessors that materialize dynamic handlers.Where repeated derivations are unavoidable, cache the derived slot after the first charged computation within the handler. Also replace the hardcoded calldata word charge with a value sourced from the active gas parameters, or document and enforce why calldata-copy charging is intentionally independent from EVM keccak pricing.
Low Risk13 findings
VecHandler indexing allows out-of-bounds storage writes
Summary
VecHandlerbounds-checksat(), but its indexing operators derive writable element handlers for any index without checking the vector length.Finding Description
VecHandler::at()treats the stored vector length as part of the collection boundary: it readslen_slotand returnsNonewhenindex >= len.The
IndexandIndexMutimplementations do not enforce that boundary. Both implementations compute and cache an element handler directly fromdata_slotand the requested index without readinglen_slot. A caller can therefore accesshandler[index]for an index outside the logical vector and then callwrite()on the returned handler.That write stores data at the slot derived for the out-of-bounds index, but it does not update the vector length. The value is unreachable through normal vector reads, can later become visible if the vector grows to that index, and violates the expected behavior of Rust's indexing operator, which should not silently provide mutable access outside the collection bounds.
Affected code:
crates/common/precompile-storage/src/types/vec.rs#L204-L215crates/common/precompile-storage/src/types/vec.rs#L257-L278
Impact Explanation
Impact is Low. This is a storage correctness issue that can create unreachable or stale data in contract storage, but no direct asset-loss path was established from the scoped code.
Likelihood Explanation
Likelihood is Medium. The unsafe path is exposed through normal Rust indexing syntax on a public handler type, while the safe
at()helper exists but is not enforced by the type system.Recommendation
We recommend making the indexing operator enforce the same bounds as
at(). SinceIndexcannot return aResult, either makeIndexandIndexMutpanic on out-of-bounds access after readinglen_slot, or remove these trait implementations and require callers to useat()andpush()APIs that can return storage errors.Stateful precompiles ignore the EIP-8037 state gas reservoir
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
J4X
Summary
Stateful native precompiles charge EIP-8037 state-gas through regular gas and return it as reservoir gas, so post-EIP-8037 account/code creation through
set_codecan be overcharged and misaccounted.Finding Description
EIP-8037 splits gas accounting into regular gas and state gas. State-gas charges should consume the state-gas reservoir first and only spill into regular gas after the reservoir is exhausted. The upstream revm/alloy path already models this separation: precompile results are converted with
Gas::new_with_regular_gas_and_reservoir(gas_limit, output.reservoir), and revm's gas model documents that state-gas charges deduct from the reservoir before spilling into regular gas.The stateful precompile storage provider drops the reservoir before any precompile storage operation can use it.
EvmPrecompileStorageProvider::newdestructuresPrecompileInputwith.., discardinginput.reservoir, and initializes a regular-only gas tracker:pub fn new(input: PrecompileInput<'a>, gas_params: GasParams) -> Self { let PrecompileInput { gas, caller, is_static, internals, .. } = input; Self { internals, caller, gas: Gas::new(gas), gas_params, is_static, state_gas_used: 0, // ... }}set_codethen applies EIP-8037 state-gas charges when it installs code for a new account:if is_new_account { self.deduct_gas(self.gas_params.create_cost())?; let num_words = code_len.div_ceil(32) as u64; self.deduct_gas(KECCAK256.saturating_add(KECCAK256WORD.saturating_mul(num_words)))?; self.deduct_state_gas(self.gas_params.create_state_gas())?; self.deduct_state_gas(self.gas_params.code_deposit_state_gas(code_len))?;}However,
deduct_state_gasalways spends regular gas and never consults a reservoir. The provider also reports that no reservoir exists:fn deduct_state_gas(&mut self, gas: u64) -> Result<()> { // No separate reservoir in the precompile context; state gas is drawn from regular gas. self.deduct_gas(gas)?; self.state_gas_used = self.state_gas_used.saturating_add(gas); Ok(())} fn reservoir(&self) -> u64 { 0}The B20 factory reaches this path during token creation. It creates a fresh token account and calls
self.storage.set_code(token_address, stub)?, so post-EIP-8037 B20 token creation performs account/code state-gas accounting through this regular-gas-only provider.The return path also swaps the state-gas and reservoir semantics.
IntoPrecompileResultpassesstate_gasas the third argument toPrecompileOutput::new:Ok(res) => Ok(PrecompileOutput::new(gas, encode_ok(res), state_gas)),But
PrecompileOutput::new(gas_used, bytes, reservoir)interprets that third argument as the remaining reservoir and initializesstate_gas_usedto zero. Therefore successful stateful precompile calls can return regular gas that already includes state-gas charges, set the returned reservoir to the amount of state gas used, and leave the actualstate_gas_usedfield unset. This is inconsistent with the EIP-8037 reservoir model and with the revm/alloy precompile result contract.Impact Explanation
Impact is Medium. Affected stateful precompile calls can consume regular gas for costs that should be absorbed by the EIP-8037 state-gas reservoir, which can reject otherwise valid calls with insufficient regular gas or overstate regular gas consumption. The same calls can also underreport
state_gas_usedwhile returning the used state gas as remaining reservoir gas, causing block-level regular/state gas accounting to diverge from the intended EIP-8037 model. No direct asset-loss path was established, so the impact is not High.Likelihood Explanation
Likelihood is Medium. The bug is deterministic for stateful native precompile paths that perform state creation with a nonzero EIP-8037 reservoir. B20 token creation reaches the affected
set_codepath for fresh token accounts. The condition depends on post-EIP-8037 execution where a nonzero reservoir is available to the precompile call, so it is not assigned High likelihood for all current executions.Proof of Concept
The issue follows from the existing accounting path:
- Execute a post-EIP-8037 stateful precompile call with nonzero
PrecompileInput.reservoir. - Reach B20 token creation, which calls
self.storage.set_code(token_address, stub)?for a fresh account. EvmPrecompileStorageProvider::newdiscards the input reservoir and createsGas::new(gas).set_codecallsdeduct_state_gas(create_state_gas)anddeduct_state_gas(code_deposit_state_gas(code_len)).- Each
deduct_state_gascall invokesdeduct_gas, reducing regular gas instead of consuming the reservoir first. - The successful result is returned through
PrecompileOutput::new(gas, bytes, state_gas), which treatsstate_gasas remaining reservoir and leavesstate_gas_usedunset.
A correct implementation would leave regular gas unchanged while the reservoir is sufficient, reduce the reservoir by the state-gas cost, and return the consumed amount through
PrecompileOutput.state_gas_used.Recommendation
We recommend preserving the precompile input reservoir in
EvmPrecompileStorageProviderand initializing the provider gas tracker with the same regular-gas/reservoir split used by revm.deduct_state_gasshould use reservoir-first semantics equivalent to revm'srecord_state_cost, andreservoir()should return the actual remaining reservoir.We recommend fixing precompile result construction so regular gas used, state gas used, and remaining reservoir are separate values. Success and revert helpers should pass the remaining reservoir to
PrecompileOutput::neworPrecompileOutput::revert, then explicitly assignout.state_gas_used = ctx.state_gas_used()before returning.SSTORE omits state-gas accounting while set_code retains it
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Jay
Description
Base's precompile storage layer tracks two gas quantities: regular gas for computational cost, and state gas for the EIP-8037 permanent storage burden. State gas is surfaced from every precompile dispatch via
state_gas_used()in the returnedPrecompileOutputand flows upward into block-level accounting throughblock_state_gas_used().The provider charges state gas asymmetrically. The bug location is the
sstoremethod ofEvmPrecompileStorageProvider:set_code, the factory contract-creation path, chargescreate_state_gaspluscode_deposit_state_gas.Sstore, every storage-slot write performed by a B20 precompile, charges no state gas at all. For a cold zero to non-zero write the dropped charge is 230,000 gas, the dominant component of the operation's true cost of roughly 252,200. The existing test suite covers only theset_codestate-gas path, so no test exercises or pins SSTORE state gas, and the omission goes undetected.This leaves a state that is neither fully on nor fully off, and exactly one of two interpretations must be true:
-
If
EIP-8037state metering is meant to be active for B20 precompiles, which requires precompiles to use the same gas path as the EVM, thensstoreis wrong. Every precompile-driven storage-slot creation escapes state-gas accounting. If a per-block state-growth limit is enforced using this counter, B20 SSTOREs that create new slots are not counted against it, allowing a block to create more persistent state than the limit intends. This is a state-bloat and underpricing risk. -
If state metering is meant to be inactive for these precompiles, consistent with Base hardcoding the reservoir to 0 and documenting that state gas is drawn from regular gas, then
set_codeis wrong. It should not be charging state gas either, andstate_gas_usedshould be uniformly zero.
The evidence leans toward an accidental omission. State gas is charged in
set_codebut not insstore, while thestate_gas_usedcounter remains fully wired into block accounting, and no test pins SSTORE state gas.Recommendation
Make state-gas accounting uniform across
sstoreandset_code. The half-on, half-off state must not ship. If metering is intended to be on, restore theSSTOREstate-gas charge by addingself.deduct_state_gas(self.gas_params.sstore_state_gas(&s.data))?after the dynamic-gas deduction. If metering is intended to be off, remove thededuct_state_gascalls fromset_codesostate_gas_usedis consistently zero.If the current divergence is intentional, it would be ideal to document it, as an intentional behavioral divergence from the upstream gas model that is invisible in the code is itself a defect.
Existing-account set_code skips EIP-8037 code-deposit state gas
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
set_codeonly charges EIP-8037 code-deposit state gas when the target account is completely empty. A user can precompute their deterministic B20 token address, send 1 wei to that address before token creation, and then call the factory. The 1 wei transfer makes the future token account existent while leaving its code empty, so the factory still accepts it but the later precompile stub code deposit skips the requiredL * CPSBstate-gas charge.Finding Description
EIP-8037 separates account-creation state gas from code-deposit state gas. For
CREATE/CREATE2with bytecode sizeL, it chargesL * CPSBwhen code is deposited into an already existent account, and(STATE_BYTES_PER_NEW_ACCOUNT + L) * CPSBonly when the account leaf is new. The EIP also states that an account with nonzero balance but no code and zero nonce is existent, and that contract creation at that address should charge only the code-deposit component.The B20 factory gives users a direct way to create this condition. A caller controls the factory salt, and the token address is deterministically derived from
(caller, variant, salt). Before callingcreateB20, the caller can compute the future token address and send 1 wei to it. That transfer creates a balance-only account leaf at the future token address. The account still has an empty code hash, so it passes the factory's deployment check, but it is no longerAccountInfo::is_empty().The production precompile storage provider does not preserve that distinction. It first loads the account and checks
AccountInfo::is_empty(), then charges both EIP-8037 state-gas components only inside that singleis_new_accountbranch:crates/common/precompile-storage/src/evm.rs#L89-L119let is_new_account = { let state_load = self .internals .load_account(address) .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; state_load.data.info.is_empty()}; if is_new_account { self.deduct_state_gas(self.gas_params.create_state_gas())?; self.deduct_state_gas(self.gas_params.code_deposit_state_gas(code_len))?;}The in-memory provider mirrors the same behavior, so tests built on it also treat every non-empty account leaf as exempt from code-deposit state gas:
crates/common/precompile-storage/src/hashmap.rs#L89-L100The reachable production path is B20 token creation.
B20FactoryStorage::create_b20computes a deterministic token address, rejects only accounts with non-empty code, and then writes a one-byte0xefstub throughset_code:crates/common/precompiles/src/b20_factory/storage.rs#L59-L71let already_deployed = self.storage.with_account_info(token_address, |info| Ok(!info.is_empty_code_hash()))?;if already_deployed { return Err(BasePrecompileError::revert(IB20Factory::TokenAlreadyExists { token: token_address, }));} let checkpoint = self.storage.checkpoint();let stub = Bytecode::new_legacy(Bytes::from_static(&[0xef]));self.storage.set_code(token_address, stub)?;Therefore, a user can save gas on B20 token creation with this sequence:
- Choose the
variantandsaltthat will be used forcreateB20. - Compute the deterministic token address derived from
(caller, variant, salt). - Send 1 wei to that future token address before creation.
- Call
createB20with the samevariantandsalt.
The factory accepts the address because the account still has no code. However, because the account now has a nonzero balance,
AccountInfo::is_empty()is false.EvmPrecompileStorageProvider::set_codethen skips bothcreate_state_gas()andcode_deposit_state_gas(1). Skippingcreate_state_gas()is correct for the existing account leaf; skippingcode_deposit_state_gas(1)is not.Impact Explanation
The impact is Low. The issue is a protocol gas-accounting undercharge rather than unauthorized state mutation, asset loss, or corruption of token balances. A caller can intentionally pay 1 wei to the future token address to make creation cheaper by avoiding the EIP-8037 code-deposit state-gas charge. In the currently reachable B20 factory path, the deposited bytecode is the one-byte
0xefmarker, so the missed EIP-8037 charge is bounded to oneCPSBunit. With the AmsterdamCPSBvalue used by the local gas parameters, that is 1,530 state gas per affected code deposit.Likelihood Explanation
The likelihood is Medium. The path is user-reachable through B20 factory creation because callers control the salt and can precompute the resulting token address. Creating the required state only requires sending 1 wei to that address before calling the factory. It is not expected to affect every token creation because fresh, never-touched addresses still take the
is_new_accountbranch and pay both state-gas components.Proof of Concept
The attack path is:
- The caller chooses a
saltandvariant. - The caller computes the future token address using the same deterministic derivation as
B20Variant::compute_address(caller, salt). - The caller transfers 1 wei to that future address, creating an existent account with empty code.
- The caller invokes
createB20with the chosensaltandvariant. create_b20checks only!info.is_empty_code_hash(), so the prefunded empty-code account passes.set_codeseesAccountInfo::is_empty() == falseand skipscode_deposit_state_gas(1).
The repository already contains focused tests demonstrating the two relevant provider branches:
crates/common/precompile-storage/src/evm.rs#L305-L341Run:
RUSTFLAGS='' cargo test -p base-precompile-storage set_code_existing_account_skips_state_gas -- --nocaptureRUSTFLAGS='' cargo test -p base-precompile-storage set_code_new_account_charges_create_and_deposit_state_gas -- --nocaptureBoth tests pass. The first confirms that the current provider deliberately leaves
state_gas_used()unchanged afterset_codeis called on an already-initialized account. The second confirms that a brand-new account is chargedcreate_state_gas() + code_deposit_state_gas(code_len). Together with the B20 factory's non-empty-code-only guard, this reproduces the missing code-deposit state-gas component for the 1 wei prefunded empty-code account.Recommendation
We recommend separating the account-leaf creation charge from the code-deposit charge in
set_code. Keepcreate_state_gas()gated onAccountInfo::is_empty(), but chargecode_deposit_state_gas(code_len)whenever non-empty code is written to an account whose current code hash is empty, including existent balance-only accounts. TheHashMapStorageProvidershould mirror the same rule so tests exercise the production accounting model.Transient Set<T> operations panic instead of returning structured errors
Summary
SetHandler<T>transient methods panic even though the handler trait returnsResult.Finding Description
The shared
Handler<T>trait defines transient operations asResult-returning methods atprovider.rs:t_readt_writet_delete
SetHandler<T>implements those methods withunimplemented!()atset.rs.A generic caller using the handler trait can therefore trigger a panic instead of receiving a structured
BasePrecompileErroror unsupported-operation error.SetandSetHandlerare publicly exported from the storage library, making this a public API hazard.Relevant code:
pub trait Handler<T: Storable> { fn t_read(&self) -> Result<T>; fn t_write(&mut self, value: T) -> Result<()>; fn t_delete(&mut self) -> Result<()>;} fn t_write(&mut self, _: Set<T>) -> Result<()> { unimplemented!("Set does not support transient storage")}Recommendation
We recommend replacing the
unimplemented!()stubs with explicit structured errors indicating that transient storage is unsupported forSet<T>.SSTORE Mutates State Before Gas Is Charged
Description
sstorecallsinternals.sstore(),which dirties and warms the slot in the revm journal before charging the static and dynamic SSTORE gas. If eitherdeduct_gasreturnsOutOfGas, the slot is already written and the provider relies entirely on an outer call-frame rollback to undo it. The provider is not locally atomic on the out-of-gas path, and affordability is checked only after the state transition used to derive the cost.Today this is masked because Base's stateful precompiles run inside a frame that revm reverts on
OutOfGas. That protection is incidental and undocumented: any directStorageProvideruse, or any future intrinsic/handler-invoked path without a surrounding frame, would observe a persisted write for an operation that should have failed.Tempo carried the identical
sstoreand fixed it:- tempoxyz/tempo #2321 — labeled
C-Bug/C-audit; description: "gas was deducted after mutating state. If an OOG error occurred mid-operation, partial state changes were already committed, leaving the chain in an inconsistent state." - tempoxyz/tempo #2329 — merged
fix (
C-Bug/C-audit) for the observed consequence: a transaction that ran out of gas left akey_authorizationwrite persisted — proving the "outer frame always reverts" assumption does not hold for every path.
Recommendation
Charge SSTORE gas before applying the journal mutation, matching the Tempo fix: compute the cold/warm classification, deduct the static cost (and confirm the cold load is affordable), then write. Base is a new chain with no legacy blocks, so this can be done unconditionally. If revm only exposes the classification through a mutating call, wrap the mutation in a
StorageCtxcheckpoint and revert it before returningOutOfGas. Add a regression test asserting a gas-failedsstoreleaves the slot unchanged and unwarmed.- tempoxyz/tempo #2321 — labeled
with_account_info passes an AccountInfo whose code field is never hydrated
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Jay
Description
with_account_infocallsload_accountand clones the resultingAccountInfointo the callback.load_accountpopulatescode_hashbut leavescodelazy, so theAccountInfohanded to the callback can carrycode: Noneeven when the account holds real bytecode.The upstream tempo implementation hydrates the code with
account.load_code()before invoking the callback, so the bytes are always present:account.load_code()?; f(&account.data.account().info);Upstream reference: tempoxyz/tempo
crates/precompiles/src/storage/evm.rs.The Base implementation omits that
load_codecall, which is the deviation. The two production callers,is_initializedand the B20 factory existence check, only readis_empty_code_hash, which depends oncode_hashand is correctly populated. No current caller dereferencescode, so the bug is latent rather than exploitable. Any future caller, or external consumer of this public trait, that readsinfo.codewould silently observe missing or empty bytecode and behave incorrectly. Omitting the load also skips the cold code load gas that upstream charges, so the precompile may undercharge relative to upstream in some cases.Recommendation
Hydrate the code before reading the info, matching upstream behavior, so
codeis always present in the callback and the cold code load gas is charged. Force the load on the account before cloning:let (info, is_cold) = { let mut state_load = self .internals .load_account(address) .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; state_load.data.load_code()?; (state_load.data.info.clone(), state_load.is_cold)};Production checkpoint commits ignore saved checkpoint tokens
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
The production EVM storage provider ignores the checkpoint token supplied by
CheckpointGuard::commit()and commits whichever checkpoint is currently at the top of the underlying journal stack.Finding Description
StorageCtx::checkpoint()returns a guard containing aJournalCheckpoint, andCheckpointGuard::commit()passes that saved token back to the storage provider. This makes the public API appear token-based: committing a guard should commit that guard's checkpoint.In the production EVM provider, however, the checkpoint argument is intentionally ignored.
checkpoint_commit()callsinternals.checkpoint_commit(), whose behavior is to pop the current top checkpoint rather than the supplied token.fn checkpoint(&mut self) -> JournalCheckpoint { self.internals.checkpoint()} fn checkpoint_commit(&mut self, _checkpoint: JournalCheckpoint) { // alloy-evm's checkpoint_commit pops the top checkpoint; the arg is unused. self.internals.checkpoint_commit();}The test provider enforces the intended LIFO discipline instead. Its
checkpoint_commit()asserts that the supplied checkpoint is the latest snapshot before popping it.fn checkpoint_commit(&mut self, checkpoint: JournalCheckpoint) { assert_eq!( checkpoint.journal_i, self.snapshots.len() - 1, "out-of-order checkpoint commit (expected top of stack)" ); self.snapshots.pop();}This creates a backend mismatch. If nested checkpoint guards are resolved out of order, the production backend commits the latest checkpoint even when the caller commits an older guard. The wrong checkpoint can be committed while the older guard remains active, and a later drop/revert can apply to a different state boundary than the caller intended. The HashMap backend catches this misuse, but production EVM execution does not, so tests can only detect the issue when they exercise the HashMap provider directly.
The same LIFO expectation applies to revert. The production backend forwards the token to
checkpoint_revert(), but there is no local assertion that the reverted checkpoint is the active top-of-stack checkpoint.fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint) { self.internals.checkpoint_revert(checkpoint);}Recommendation
We recommend enforcing LIFO checkpoint usage in
EvmPrecompileStorageProviderbefore committing or reverting. A debug-only stack of active checkpoint identifiers is sufficient to catch API misuse during tests and debug builds while preserving release behavior. The guard should track checkpoint creation and assert that each commit or revert resolves the most recently created checkpoint. Alternatively, redesign the checkpoint API so out-of-order guard resolution is unrepresentable.HashMap checkpoint rollback omits production-relevant state
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Summary
The HashMap storage provider snapshots persistent storage slots and events, but omits transient storage and account/code state that should participate in checkpoint rollback.
Finding Description
HashMapStorageProvider::Snapshotcurrently stores onlyinternalsandeventsathashmap.rs. The checkpoint implementation snapshots only those fields athashmap.rs.Current snapshot coverage:
Field Currently snapshotted? Should rollback on checkpoint revert? Rationale internalsYes Yes Persistent storage writes made after a checkpoint should be reverted. eventsYes Yes Logs emitted inside reverted execution should be discarded. transientNo Yes Transient storage writes made after a checkpoint should be reverted if the checkpoint is reverted. accountsNo Yes set_codemutates account bytecode after checkpoint creation in factory-style flows, and that code/account state should disappear if initialization reverts.callerNo No Caller is call-context state; the only runtime mutation path is with_caller, which uses a guard to restore the previous caller on scope exit, including early returns.is_staticNo No Static mode is call-context configuration and is only mutated in the HashMap backend through test setup helpers. counter_sload/counter_sstoreNo No These are test instrumentation for attempted operations, and attempts still occurred even if the state changes are reverted. gas_paramsNo No Gas parameters are configuration for the call/test setup, not journaled EVM state. state_gas_usedand other gas accountingNo No Gas and state-gas accounting represent resources consumed during attempted execution; failed or reverted execution normally still burns gas. Public provider methods mutate omitted state:
set_codeupdates account bytecode athashmap.rststoreupdates transient storage athashmap.rs
These state changes are not restored by HashMap checkpoint rollback even though the production EVM journal should roll them back.
Relevant code:
struct Snapshot { internals: HashMap<(Address, U256), U256>, events: HashMap<Address, Vec<LogData>>,} fn checkpoint(&mut self) -> JournalCheckpoint { self.snapshots .push(Snapshot { internals: self.internals.clone(), events: self.events.clone() }); // ...}Recommendation
We recommend expanding
Snapshotto include the omitted mutable fields that are expected to participate in checkpoint atomicity:- Transient storage
- Account/code state
Do not roll back static mode, gas parameters, gas/state-gas accounting, or operation counters. Those fields should remain outside checkpoint atomicity, and the provider API or tests should document that boundary explicitly.
Slot offset arithmetic can overflow or alias slots
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
Several storage handlers derive child slots with unchecked or saturating
U256arithmetic instead of consistently returningSlotOverflow.Finding Description
The storage helpers derive child storage slots by adding offsets to a base slot. The crate already defines
BasePrecompileError::SlotOverflow, and generated struct-array code useschecked_add()and returns that error when slot arithmetic exceedsU256::MAX.Other scoped type paths do not follow the same rule:
Slot::new_at_offset()andSlot::new_at_loc()usesaturating_add(), so different overflowing offsets can alias toU256::MAX.- Primitive array code uses unchecked
+onU256. VecHandleruses unchecked+onU256.- Bytes-like data chunk loops use unchecked
+onU256. SetHandleruses unchecked+onU256.- The default
Storable::delete()path uses unchecked+onU256.
The
ruintimplementation backing AlloyU256implementsAddaswrapping_add, so overflowed slot derivation can wrap to low storage slots instead of being rejected.This makes slot derivation inconsistent across the storage abstraction. A layout rooted near
U256::MAX, whether by explicit slot selection, namespace root, or derived dynamic data slot, can produce wrapped or saturated child slots and potentially read or write a different storage location than intended.Affected code:
Relevant code:
slot: base_slot.saturating_add(U256::from_limbs([offset_slots as u64, 0, 0, 0])), let slot = if T::BYTES <= 16 { data_start + U256::from(location.offset_slots)} else { data_start + U256::from(index * T::SLOTS)}; for offset in 0..Self::SLOTS { storage.store(slot + U256::from(offset), U256::ZERO)?;}Recommendation
We recommend using checked slot arithmetic consistently for every derived storage slot and returning
BasePrecompileError::SlotOverflowon failure. This should include:Slot::new_at_offset()Slot::new_at_loc()- Array handlers
- Vector handlers
- Bytes-like chunk loops
- Set position base derivation
- The default multi-slot delete path
install(address) override can desynchronize dispatch and storage addresses
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
J4X
Summary
The
#[precompile]macro allows aninstall(address = ...)override to register a precompile at one address while generated dispatch still constructs storage bound to the storage type's canonical contract address.Finding Description
The
#[precompile]macro parsesinstall(address = ...)/install(addr = ...)as an arbitrary install address expression. When present, that expression is used as the key inserted into the EVMPrecompilesMap:let install = config.install.map(|install| { let address = install .address .map_or_else(|| quote! { <#storage>::ADDRESS }, |address| quote! { #address }); quote! { pub fn install( precompiles: &mut ::alloy_evm::precompiles::PrecompilesMap, #(#install_arg_defs),* ) { precompiles.extend_precompiles(::core::iter::once(( #address, Self::precompile(#(#install_arg_names),*), ))); } }});The generated runtime wrapper does not pass that install address into the storage object. It always constructs storage through
<Storage>::new(ctx):pub fn precompile(#(#arg_defs),*) -> ::alloy_evm::precompiles::DynPrecompile { #macro_path!(#id, |ctx, calldata| { <#storage>::new(ctx).dispatch(ctx, &calldata #(, #arg_names)*) })}The storage constructor generated by
#[contract(addr = ...)]binds the contract address intonew()and stores it inself.address. Subsequent initialization and event emission useself.address, not the precompile-map key used byinstall(address = ...):pub fn new(storage: ::base_precompile_storage::StorageCtx<'a>) -> Self { Self::__new(#addr, storage)} fn __initialize(&mut self) -> ::base_precompile_storage::Result<()> { let bytecode = ::revm::state::Bytecode::new_legacy(::alloy_primitives::Bytes::from_static(&[0xef])); self.storage.set_code(self.address, bytecode)?; Ok(())} fn emit_event(&mut self, event: impl ::alloy_primitives::IntoLogData) -> ::base_precompile_storage::Result<()> { self.storage.emit_event(self.address, event.into_log_data())}As a result, a precompile declared with mismatched values such as
#[precompile(install(address = A))]and#[contract(addr = B)]can be installed and called at addressA, while the generated storage, bytecode marker, and event emission operate under addressB.Current scoped usage did not show a concrete in-repository
#[precompile(install(address = ...))]instantiation with divergent addresses. The issue is therefore an integration hazard in the macro API: the macro accepts an unsafe configuration without enforcing that dispatch identity and storage identity remain the same.Recommendation
We recommend rejecting mismatched install and storage addresses at macro expansion time, or removing the independent install-address override. If an override is still needed, thread the chosen install address into the generated storage constructor so that the precompile-map key, storage address, bytecode marker, and emitted-event address all derive from the same value.
Namespace metadata constants can collide with contract field
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
Contract-level storage namespaces emit fixed metadata constants into the generated
slotsmodule, but the#[contract]field parser does not reserve the source field names that normalize to those constants. A contract field namednamespace_idornamespace_roottherefore makes the macro generate duplicateNAMESPACE_IDorNAMESPACE_ROOTconstants and prevents the storage layout from compiling.This is a Low severity issue: it can block developers from defining otherwise valid namespace-bearing storage layouts, but the observed impact is compile-time denial of use rather than runtime asset loss.
Finding Description
The
#[contract]macro rejects only three exact field names:address,storage, andmsg_sender. The comparison is performed against the original source identifier string, before the identifier is normalized into the uppercase constant name used in the generatedslotsmodule.crates/common/precompile-macros/src/contract.rs#L37-L37pub(crate) const RESERVED: &[&str] = &["address", "storage", "msg_sender"];crates/common/precompile-macros/src/contract.rs#L106-L110if RESERVED.contains(&name.to_string().as_str()) { return Err(syn::Error::new_spanned( name, format!("Field name '{name}' is reserved"), ));}Field slot constants are generated from the field name by uppercasing it. For example, a field named
namespace_idbecomesNAMESPACE_ID.crates/common/precompile-macros/src/packing.rs#L40-L42pub(crate) fn const_name(name: &Ident) -> String { name.to_string().to_uppercase()}When a contract-level namespace is present, the macro emits namespace metadata constants and the field constants into the same generated
slotsmodule.crates/common/precompile-macros/src/layout.rs#L243-L250quote! { /// Storage slot indices and packing constants for this contract. pub mod slots { use super::*; #namespace_constants #constantscrates/common/precompile-macros/src/layout.rs#L259-L265quote! { /// ERC-7201 namespace identifier for this contract storage layout. pub const NAMESPACE_ID: &str = #id; /// ERC-7201 namespace root slot for this contract storage layout. pub const NAMESPACE_ROOT: ::alloy_primitives::U256 =As a result, this otherwise normal namespace-bearing layout cannot compile:
#[contract(addr = TEST_ADDR)]#[namespace("collision.test")]pub struct NamespaceCollision { pub namespace_id: U256,}The field passes the reserved-name check because
"namespace_id"is not inRESERVED, but field constant generation then emitspub const NAMESPACE_ID: U256 = ...into the same module that already containspub const NAMESPACE_ID: &str = ...for the namespace metadata. The same root cause applies tonamespace_root, which normalizes toNAMESPACE_ROOT.During validation, the related case-sensitive
AddRess/ADDRESShypothesis was not reproduced as a collision with a separate macro-generated reserved constant inslots; the confirmed issue is the namespace metadata collision.Proof of Concept
-
Check out the audited repository at commit
6ee3da6325e7416812065c58358274a086f9f223. -
From any working directory outside the repository, create a minimal Rust crate that depends on the local macro and storage crates:
export BASE_REPO=/path/to/basemkdir -p base-macro-collision-poc/srccat > base-macro-collision-poc/Cargo.toml <<EOF[package]name = "base-macro-collision-poc"version = "0.1.0"edition = "2024" [dependencies]alloy-primitives = { version = "1.5.6", default-features = false }base-precompile-macros = { path = "$BASE_REPO/crates/common/precompile-macros" }base-precompile-storage = { path = "$BASE_REPO/crates/common/precompile-storage", features = ["test-utils"] }revm = { version = "38.0.0", default-features = false }EOF- Add a namespace-bearing contract layout with a field named
namespace_id:
cat > base-macro-collision-poc/src/lib.rs <<'EOF'use alloy_primitives::{Address, U256, address};use base_precompile_macros::contract; const TEST_ADDR: Address = address!("0000000000000000000000000000000000001234"); #[contract(addr = TEST_ADDR)]#[namespace("collision.test")]pub struct NamespaceCollision { pub namespace_id: U256,}EOF- Compile the reproduction crate:
RUSTFLAGS= CARGO_ENCODED_RUSTFLAGS= cargo check --manifest-path base-macro-collision-poc/Cargo.tomlThe command fails with the duplicate generated constant:
error[E0428]: the name `NAMESPACE_ID` is defined multiple times --> src/lib.rs:7:5 |7 | #[contract(addr = TEST_ADDR)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NAMESPACE_ID` redefined here | = note: `NAMESPACE_ID` must be defined only once in the value namespace of this module = note: this error originates in the attribute macro `contract`Recommendation
We recommend reserving every generated
slotsitem name before code generation, using the same normalization logic that produces field constant identifiers. At minimum, reject field identifiers whose generated slot constant isNAMESPACE_IDorNAMESPACE_ROOTwhen a contract-level namespace is present. A more robust fix is to build a set of all generated item identifiers for each field, including slot, offset, debug byte-size, collision-check, and namespace metadata names, and fail early with a clearsyn::Errorif any generated names collide.Case-normalized field names can generate duplicate slot constants
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
The precompile storage macros generate slot constant names by uppercasing field identifiers, but they do not reject field names that collide after this normalization. A storage layout containing case-distinct Rust fields such as
fooandFOOtherefore reaches macro code generation and fails with duplicate generated constants instead of receiving a deterministic validation error.Finding Description
The macro layer accepts normal Rust field identifiers and later derives generated slot constant identifiers from a lossy uppercase representation of each field name.
PackingConstants::newstoresconst_name(name), andconst_nameuppercases the original identifier:crates/common/precompile-macros/src/packing.rs#L16-L42impl PackingConstants { pub(crate) fn new(name: &Ident) -> Self { Self(const_name(name)) } pub(crate) fn slot(&self) -> Ident { format_ident!("{}", &self.0) } pub(crate) fn offset(&self) -> Ident { let span = proc_macro2::Span::call_site(); Ident::new(&format!("{}_OFFSET", self.0), span) }} pub(crate) fn const_name(name: &Ident) -> String { name.to_string().to_uppercase()}Those normalized names are then emitted as public slot and offset constants for every allocated field:
crates/common/precompile-macros/src/packing.rs#L117-L153for field in fields { let ty = field.ty; let consts = PackingConstants::new(field.name); let (loc_const, (slot_const, offset_const)) = (consts.location(), consts.into_tuple()); // ... constants.extend(quote! { #[doc = #slot_doc] pub const #slot_const: ::alloy_primitives::U256 = #slot_expr; #[doc = #offset_doc] pub const #offset_const: usize = #offset_expr; });The
#[contract]parser only rejects exact reserved field names, so it does not catch case-normalized collisions before code generation:crates/common/precompile-macros/src/contract.rs#L98-L122named_fields .into_iter() .map(|field| { let name = field .ident .as_ref() .ok_or_else(|| syn::Error::new_spanned(&field, "Fields must have names"))?; if RESERVED.contains(&name.to_string().as_str()) { return Err(syn::Error::new_spanned( name, format!("Field name '{name}' is reserved"), )); }The same packing path is also used by
#[derive(Storable)], which copies each original field identifier intoFieldInfoand then callspacking::allocate_slotsbefore generating the packing module:crates/common/precompile-macros/src/storable.rs#L75-L97for field in fields { let name = field.ident.as_ref().unwrap().clone(); field_infos.push(FieldInfo { name: name.clone(), ty: field.ty.clone(), slot: None, base_slot: None, namespace: None, }); // ...} let layout_fields = packing::allocate_slots(&field_infos)?;let packing_module = gen_packing_module_from_ir(&layout_fields, &mod_ident);A minimal validation harness with a
#[contract]storage struct containing bothfooandFOOreached macro expansion and failed with Rust duplicate-item errors for the generated constantsFOO,FOO_OFFSET, and, in debug builds,FOO_BYTES. This is not a Solidity restriction: Solidity identifiers are case-sensitive, and an otherwise equivalent Solidity contract withfooandFOOstate variables compiles successfully. The mismatch matters because these Rust macros are intended to mirror Solidity-style storage layouts, but they impose an additional lossy normalization step on generated constant names. The currently inspected in-scope production storage layouts do not contain such a case-normalized duplicate pair, so the practical impact is limited to a future bad layout causing confusing generated-code compilation failures rather than current runtime storage corruption.Proof of Concept
The following steps are self-contained except for requiring a checkout of
base/baseat the audited commit. They show that Solidity accepts case-distinct state variables while the Base Rust storage macro rejects the analogous layout only after generating duplicate constants.First, check out the audited Base commit and install the Rust toolchain used by the workspace:
git clone https://github.com/base/base.git base-case-collisioncd base-case-collisiongit checkout 6ee3da6325e7416812065c58358274a086f9f223rustup toolchain install 1.94.1Then compile an equivalent Solidity contract.
npxdownloads the pinned JavaScriptsolcpackage, so no global Solidity compiler install is required:cat >/tmp/CaseCollision.sol <<'EOF'// SPDX-License-Identifier: MITpragma solidity ^0.8.20; contract CaseCollision { uint256 public foo; uint256 public FOO;}EOF npx --yes [email protected] --bin /tmp/CaseCollision.solThis command succeeds, demonstrating that Solidity itself allows
fooandFOOas distinct identifiers.Next, create a minimal Rust crate that depends on the local Base macro and storage crates from the checked-out repository:
BASE_REPO="$(pwd)"POC_DIR="/tmp/base-case-slot-collision-poc"rm -rf "$POC_DIR" /tmp/base-case-slot-collision-poc-targetmkdir -p "$POC_DIR/src" cat >"$POC_DIR/Cargo.toml" <<EOF[package]name = "base-case-slot-collision-poc"version = "0.1.0"edition = "2024" [workspace] [dependencies]revm = "38.0.0"alloy-primitives = { version = "1.5.6", default-features = false }base-precompile-macros = { path = "$BASE_REPO/crates/common/precompile-macros" }base-precompile-storage = { path = "$BASE_REPO/crates/common/precompile-storage" }EOF cat >"$POC_DIR/src/lib.rs" <<'EOF'use base_precompile_macros::contract; #[contract]pub struct CaseCollisionStorage { pub foo: u8, pub FOO: u8,}EOFFinally, run the Rust compile check from outside the Base repository so Cargo does not load the repository-local linker configuration:
cd /tmpenv -u RUSTFLAGS -u CARGO_ENCODED_RUSTFLAGS CARGO_TARGET_DIR=/tmp/base-case-slot-collision-poc-target \ cargo +1.94.1 check --manifest-path "$POC_DIR/Cargo.toml"The Rust command fails with duplicate definitions for
FOO,FOO_OFFSET, andFOO_BYTES, all originating from the#[contract]attribute macro:error[E0428]: the name `FOO` is defined multiple times = note: this error originates in the attribute macro `contract` error[E0428]: the name `FOO_OFFSET` is defined multiple times = note: this error originates in the attribute macro `contract` error[E0428]: the name `FOO_BYTES` is defined multiple times = note: this error originates in the attribute macro `contract`Recommendation
We recommend tracking the normalized constant names during macro parsing and returning a
syn::Errorwhen two fields map to the same generated constant prefix. Apply the check to both#[contract]field parsing and#[derive(Storable)]struct parsing, and include both original field names plus the colliding generated prefix in the diagnostic. For example, rejectfooandFOObefore emitting anyslots::FOO,FOO_OFFSET, or packing-module constants.
Informational20 findings
EIP-3541 comment documents the wrong set_code behavior
Summary
EvmPrecompileStorageProvider::set_codecontains a misleading EIP-3541 comment: the code only charges bytecode deposit gas and does not enforce EIP-3541's0xeffirst-byte rejection. The0xefmarker itself is an intentional privileged precompile-factory marker and is not a protocol violation.Finding Description
Normal EVM contract creation rejects runtime bytecode whose first byte is
0xefafter London. Therevmcreate finalization path performs that validation before depositing code:// EIP-3541: Reject new contract code starting with the 0xEF byteif !is_eip3541_disabled && spec_id.is_enabled_in(LONDON) && interpreter_result.output.first() == Some(&0xEF){ journal.checkpoint_revert(checkpoint); interpreter_result.result = InstructionResult::CreateContractStartingWithEF; return;}The native precompile storage path does not use this creation finalization logic.
EvmPrecompileStorageProvider::set_codecharges code-deposit and account-creation gas, then directly delegates tointernals.set_codeatcrates/common/precompile-storage/src/evm.rs#L89-L123:fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> { let code_len = code.len(); // EIP-3541 / Yellow Paper G_codedeposit: 200 gas per byte of deployed bytecode. self.deduct_gas(self.gas_params.code_deposit_cost(code_len))?; let is_new_account = { /* load account and check emptiness */ }; if is_new_account { self.deduct_gas(self.gas_params.create_cost())?; /* hash and state gas charges */ } self.internals .set_code(address, code) .map_err(|e| BasePrecompileError::Fatal(e.to_string()))}alloy_evm::EvmInternals::set_codeonly hashes and writes the bytecode to the account; it does not perform EIP-3541 first-byte validation. The audited code intentionally uses this privileged path to install0xefruntime bytecode in production flows. Generated precompile initialization writes a one-byte legacy0xefstub atcrates/common/precompile-macros/src/layout.rs#L183-L185:fn __initialize(&mut self) -> ::base_precompile_storage::Result<()> { let bytecode = ::revm::state::Bytecode::new_legacy( ::alloy_primitives::Bytes::from_static(&[0xef]), ); self.storage.set_code(self.address, bytecode)?; Ok(())}The B20 factory uses the same pattern for token creation at
crates/common/precompiles/src/b20_factory/storage.rs#L69-L71:let checkpoint = self.storage.checkpoint();let stub = Bytecode::new_legacy(Bytes::from_static(&[0xef]));self.storage.set_code(token_address, stub)?;Therefore,
CREATEandCREATE2remain EIP-3541-compliant, while native precompile account creation/code installation can create code-bearing accounts whose runtime code starts with0xef. The customer confirmed that this distinction is intentional: because EIP-3541 prevents ordinary post-LondonCREATE/CREATE2deployments from producing runtime code beginning with0xef, a correctly validated0xef-prefixed native marker can serve as evidence that the account was created through the privileged precompile factory rather than as a normal contract.The issue is limited to the nearby
set_codecomment. It refers to EIP-3541 while the line only charges Yellow PaperG_codedepositgas and does not enforce the EIP-3541 first-byte rule. This makes the code appear to implement EIP-3541 behavior when it actually implements gas accounting for privileged code installation.Impact Explanation
Impact is informational. The confirmed behavior does not create a direct funds-loss path or an EIP-3541 compliance failure for ordinary contract creation. The risk is reviewer and maintainer confusion: the comment documents an EIP-3541 enforcement behavior that the function does not provide, which can lead future changes to rely on a nonexistent validation check.
Likelihood Explanation
Likelihood is high because the misleading comment is present in the shared
set_codepath. Any reviewer or future maintainer reading the code can reasonably infer that the path handles EIP-3541 semantics, even though it only charges code-deposit gas.Recommendation
Remove the EIP-3541 reference from the code-deposit gas comment, or replace it with wording that documents the intended split explicitly: ordinary post-London
CREATE/CREATE2deployments reject0xefruntime code, while this privileged precompile storage path only charges code-deposit gas and may intentionally install a validated0xefmarker.Unused / unreachable code in the B20-asset and activation precompiles
Description
Two spots contain code that is declared or executed but never effectively reached, leaving the implementation out of step with its stated intent.
1. Unreachable announcement reentrancy guard
In
b20_asset/dispatch.rstheannouncefunction rejects re-entry withis_announcement_active():if self.is_announcement_active() { return Err(BasePrecompileError::revert(IB20Asset::AnnouncementInProgress {}));}The
in_announcementflag is only set after this check, bybegin_announcement()intoken.rs. The single re-entry vector is the internal call loop, which dispatches each call on the sameselfviainner_with_privilegewhilein_announcement == true. That loop already rejects anyannounceselector before dispatch:if call_bytes[..4] == IB20Asset::announceCall::SELECTOR { return Err(BasePrecompileError::revert(IB20Asset::AnnouncementInProgress {}));}The only caller of
self.announceisSC::announce. No other handler invokes it. So the selector check always fires first and theis_announcement_activebranch can never be the control that catches a re-entrant call. The token is also built fresh per top level invocation within_announcementset to false, so there is no cross invocation hole. The doc comment onannounceoverstates the flag's role. In practice the selector check does the work andin_announcementis a redundant backstop.2. Unused AlreadyDeactivated ABI error
AlreadyDeactivated(bytes32 feature)is declared inactivation/abi.rsbut is never returned anywhere in the in scope crate. The only other matches in the tree are the unrelatedValidatorAlreadyDeactivatedin the out of scope.tempo-comparereference. Whenset_activateddeactivates a feature that is already off, it returnsFeatureNotActivatedinstead, inactivation/storage.rs:let current = self.features.at(&feature).read()?;if current == activated { if activated { return Err(BasePrecompileError::revert(IActivationRegistry::AlreadyActivated { feature, })); } return Err(BasePrecompileError::revert(IActivationRegistry::FeatureNotActivated { feature, }));}This reuses the same
FeatureNotActivatederror thatcheckActivated, which routes throughensure_activated, emits when querying a non activated feature. Callers cannot distinguish a deactivate of something already off from a feature that is not activated. The activate path has a symmetric error,AlreadyActivated, but the deactivate path has no in use counterpart, leavingAlreadyDeactivateddead.Neither item is exploitable. Both are correctness hygiene issues where the code does not match its declared intent.
Recommendation
1. Announcement guard
Keep the
is_announcement_activecheck as cheap defense in depth, since it hardens against a future handler that might callannounceindirectly, but correct the doc comment to state that the selector check is the active control andin_announcementis a redundant backstop. If minimalism is preferred, the check may be dropped in favor of the selector check alone, but retaining it costs nothing since it is an in memory bool.2. AlreadyDeactivated error
Pick one consistent option.
First option, wire it up, which is preferred for symmetry. Return
IActivationRegistry::AlreadyDeactivated { feature }in the already off branch ofset_activated, mirroring theAlreadyActivatedbranch. This removes the collision with theFeatureNotActivatederror used bycheckActivatedand makes the error set symmetric. Add or adjust a test asserting the deactivate when already off path reverts withAlreadyDeactivated.Second option, drop it. Remove
AlreadyDeactivatedfrom the ABI to eliminate an unused, misleading declaration.StorageKey rustdoc incorrectly documents divergent mapping key encodings as Solidity-compatible
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
The public
StorageKey::mapping_slotrustdoc describes every supported mapping key as using Solidity mapping slot derivation, while the crate README documents intentional encoding divergences for signed integers and short fixed bytes.Finding Description
The precompile storage README explicitly documents that mapping slot derivation is only Solidity-compatible for a subset of key types. It states that unsigned integers,
Address,FixedBytes<32>, andStringmatch Solidity, but signed integers andFixedBytes<N>forN < 32intentionally diverge:slot(key, base) = keccak256(lpad32(key) | to_be32(base))crates/common/precompile-storage/README.md#L53-L66The public rustdoc on
StorageKey::mapping_slotomits those caveats and documents the method as a general Solidity mapping-slot implementation:/// Computes `keccak256(lpad32(key) || slot_be32)` -- the Solidity mapping slot derivation.fn mapping_slot(&self, slot: U256) -> U256 { let key_bytes = self.as_storage_bytes(); let key_bytes = key_bytes.as_ref(); debug_assert!(key_bytes.len() <= 32);crates/common/precompile-storage/src/provider.rs#L314-L330The generated
StorageKeyimplementations confirm that the README caveat is real. Signed Alloy integers use the raw signed value bytes, whileFixedBytes<N>keys useself.as_slice()and are then left-padded bymapping_slot:StorageKeyStrategy::SignedRaw(size) => quote! { self.into_raw().to_be_bytes::<#size>() },StorageKeyStrategy::AsSlice => quote! { self.as_slice() },crates/common/precompile-macros/src/storable_primitives.rs#L56-L68This creates inconsistent public guidance for integrators. A reader relying on the trait rustdoc can reasonably reconstruct off-chain mapping slots as if all
StorageKeyimplementations matched Solidityabi.encode(key, slot), while the README correctly warns that short fixed bytes and signed integer keys do not. The runtime behavior is internally consistent, but the rustdoc can lead external tooling, tests, or storage inspection scripts to derive the wrong keys for the divergent key types.Recommendation
We recommend updating the
StorageKey::mapping_slotrustdoc to mirror the README's compatibility caveat. The documentation should state that the formula is Solidity-compatible only for unsigned integers,Address,FixedBytes<32>, and string-keyed mappings, and that signed integers plusFixedBytes<N>forN < 32intentionally diverge. Consider linking directly to the README section so future changes keep the two public descriptions aligned.Packing module rustdoc uses stale Layout::Slot and misstates fixed-array layout behavior
Summary
The
precompile-storagepacking module rustdoc references a non-existentLayout::Slotvariant and incorrectly states that fixed-size arrays are non-primitives using that layout, while the generated implementations useLayout::Slots(...)and pack eligible primitive array elements.Finding Description
The module-level rustdoc for the packing utilities describes the packing model as follows:
//! Packing only applies to primitive types where `LAYOUT::Bytes(count) && count < 32`.//! Non-primitives (structs, fixed-size arrays, dynamic types) have `LAYOUT = Layout::Slot`.crates/common/precompile-storage/src/packing.rs#L1-L13This documentation is stale. The storage layout enum does not define
Layout::Slot; it defines byte-sized layouts and multi-slot layouts. Array and struct-like values useLayout::Slots(...), notLayout::Slot.pub enum Layout { /// Single slot, N bytes (1-32). Can be packed with other fields if N < 32. Bytes(usize), /// Occupies N full slots. Cannot be packed. Slots(usize),}crates/common/precompile-storage/src/provider.rs#L173-L180The generated fixed-size array implementation also contradicts the rustdoc. Arrays are emitted with
Layout::Slots(slot_count), whereslot_countis calculated from packed element capacity when the element type is smaller than 32 bytes:let slot_count_expr = if *elem_is_packable { quote! { ::base_precompile_storage::calc_packed_slot_count(#array_size, #elem_byte_count) }} else { quote! { #array_size }}; impl ::base_precompile_storage::StorableType for [#elem_type; #array_size] { const LAYOUT: ::base_precompile_storage::Layout = ::base_precompile_storage::Layout::Slots(#slot_count_expr);crates/common/precompile-macros/src/storable_primitives.rs#L293-L317The generated array load/store paths then use
calc_element_slot,calc_element_offset,extract_from_word, andinsert_into_wordfor packable primitive elements:let slot_idx = calc_element_slot(i, #elem_byte_count);let offset = calc_element_offset(i, #elem_byte_count);let slot_addr = base_slot + ::alloy_primitives::U256::from(slot_idx);let slot_value = storage.load(slot_addr)?;result[i] = extract_from_word(slot_value, offset, #elem_byte_count)?;let slot_count = ::base_precompile_storage::calc_packed_slot_count(#array_size, #elem_byte_count);for slot_idx in 0..slot_count { let slot_addr = base_slot + ::alloy_primitives::U256::from(slot_idx); let mut slot_value = ::alloy_primitives::U256::ZERO;crates/common/precompile-macros/src/storable_primitives.rs#L350-L379The dedicated array handler documentation states the current behavior correctly: fixed-size arrays start at their base slot, and small elements are packed while larger elements use full slots.
//! Fixed-size arrays `[T; N]` use Solidity-compatible array storage://! - **Base slot**: Arrays start directly at `base_slot` (not at keccak256)//! - Small elements (`T::BYTES` <= 16) are packed; larger elements use full slots.crates/common/precompile-storage/src/types/array.rs#L1-L5The runtime behavior is not broken by this rustdoc, but the public module documentation gives integrators and reviewers the wrong mental model for fixed-size array storage. This can cause storage-layout reviews, migration notes, or off-chain decoding tools to reserve one slot per fixed-array element even when the generated implementation packs eligible elements into fewer slots.
Recommendation
We recommend updating the
packing.rsmodule rustdoc to use the real enum variant names and describe fixed-size arrays separately. The documentation should state that primitives useLayout::Bytes(count), multi-slot values useLayout::Slots(count), and fixed-size arrays useLayout::Slots(...)with primitive elements packed according tocalc_packed_slot_countwhen eligible.Generated store logic carries stale comments referencing nonexistent upstream code
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Description
The store-implementation codegen emits the same comment in two places. Both comments reference an
is_t4()helper and a "Category N" taxonomy. Neitheris_t4()nor the "Category N" classification exists anywhere in this repository, as confirmed by search. They are leftovers carried over from the upstream Tempo precompiles-macros.The practical effect is that an auditor or reviewer reading the generated store logic is given a false mental model, implying that a specific optimization once existed here and was deliberately disabled. No such optimization is present in this codebase, so the comments are misleading. This is the same category of issue as the other misleading-documentation findings.
Recommendation
Remove or rewrite both comments so they describe the actual behavior of the current code. The intended meaning is simply that the slot is always loaded before packing, so the comment should state that directly without referencing the removed
is_t4()helper or the "Category N" taxonomy.Mapping-Only Storable Structs Emit Invalid IS_DYNAMIC
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Description
A struct deriving
#[derive(Storable)]with only mapping fields produces uncompilable code. The macro buildsIS_DYNAMICby joining the direct field types with||via aquote!repetition. When every field is a mapping,direct_tysis empty, the repetition expands to nothing, and the result isconst IS_DYNAMIC: bool = ;, which rustc rejects with an "expected expression" error.Recommendation
Emit
const IS_DYNAMIC: bool = false;whendirect_tysis empty.Decimals Default Uses a Magic Literal Instead of the Named Bound
Description
The generated
decimalsaccessor falls back to a hardcoded literal6when the stored value is zero. The canonical source of this default isB20AssetStorage::MIN_DECIMALS:pub const MIN_DECIMALS: u8 = 6;The standalone
B20AssetStorage::decimalsmethod in the same file correctly usesSelf::MIN_DECIMALSfor its fallback:pub fn decimals(&self) -> Result<u8> { let decimals = self.asset.decimals()?; Ok(if decimals == 0 { Self::MIN_DECIMALS } else { decimals }) }There are now two unsynchronized definitions of the same default. They agree today because both are
6, but ifMIN_DECIMALSwere ever changed the generated macro path would silently diverge from the hand-written accessor while neither produces a compile error.This is an internal inconsistency rather than a behavioral bug. The
multiplierfallback immediately above it in the same file does this correctly by referring to the namedSelf::WADconstant instead of an inline literal, so the hardcoded6stands out as the odd case.Recommendation
Replace the literal
6with the named constantB20AssetStorage::MIN_DECIMALSso the generated accessor and the hand-written accessor share a single source of truth, matching how themultiplierfallback already usesSelf::WAD.Dead Code in Generated role_admin Default Fallback Branch
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Description
The generated
role_adminaccessor is intended to express the convention that an unset admin role defaults toDEFAULT_ADMIN_ROLE. In practice the conditional is a no-op.B20TokenRole::DefaultAdmin.id()resolves toB256::ZERO:pub const fn id(self) -> B256 { match self { Self::DefaultAdmin => B256::ZERO,In the
ifbranch the guardadmin_role.is_zero()is true and the returned value isDefaultAdmin.id(), which equalsB256::ZERO, the exact valueadmin_rolealready holds. Both arms therefore return identical values and the conditional has no observable effect.The behavior is correct today only because the OpenZeppelin convention happens to make
DEFAULT_ADMIN_ROLEequal to zero. The intended "unset admin resolves to the default admin" guarantee is not actually implemented in this code. It is accidentally satisfied by the zero constant. IfDefaultAdmin.id()were ever changed to a nonzero value, this fallback would silently break. An unset role's admin would resolve to the zero role rather than the default admin, so anensure_rolecheck againstrole_adminwould validate against the wrong role. Notably, the upstream Tempo equivalent has no such branch. It reads the stored admin directly and documents that an unset entry reads as zero, which is the default admin. The branch addition here gives a false impression of implementing a fallback.Recommendation
Either remove the conditional and read the stored admin directly, matching the upstream model where an unset entry is zero and zero is the default admin, or implement the fallback so it does not depend on the default admin being zero. If the fallback is retained for clarity, add a comment stating that it relies on
DEFAULT_ADMIN_ROLEbeing zero so the dependency is explicit rather than implicit.Namespace macro contract branch is unreachable under documented usage
Description
Attribute macros expand outermost first. In the documented layout,
#[contract]is written above#[namespace()], so the compiler expands#[contract]first and that macro receives the struct with the#[namespace]attribute still attached.#[contract]reads the namespace directly throughextract_namespace, emits the final struct, andnamespace::expandnever runs. The contract branch innamespace.rstherefore never executes for the documented ordering.That branch only runs when the attributes are inverted, with
#[namespace()]written above#[contract]. This ordering is not documented and appears in exactly one place, thenamespace_outer_ordertest, which keeps the branch alive. Both orderings produce the same correct output, so this is a maintainability issue rather than a behavioral bug.The same dead path also contains redundant validation. The early
parse_namespace_idcall inexpand_implvalidates the namespace id purely for its side effect and discards the result, then forwards the raw literal downstream whereextract_namespaceandextract_storage_namespaceparse and validate it again. The two validation sites could drift if one is changed without the other.Recommendation
Remove the contract branch and the inverted order test, and let
#[contract]own namespace handling exclusively, or document and support the attribute ordering as an explicit contract. In either case drop the redundantparse_namespace_idcall so namespace validation has a single source of truth.install Parser Reports a Misleading Error for Unsupported Options
Description
InstallConfig::parsehandles the contents ofinstall()for the#[precompile]macro. It parsesaddress = <expr>oraddr = <expr>, then tries to enforce that nothing else follows. The intent is that an unsupported trailing token produces the dedicated messageunexpected install option, which names the real problem for the macro author.The two trailing checks run in the wrong order. After the address expression is parsed, the first block tests whether any input remains and, if so, immediately requires a comma. For the common malformed case
install(address = X extra), the leftover token isextra, not a comma, soparse::<Token![,]>()fails first and emits an expected comma error pointing atextra. The author is told a comma is missing when the actual issue is thatextrais an unsupported option. The dedicatedunexpected install optionmessage on the second block is only reachable when a comma was genuinely present, as ininstall(address = X, extra), so the clearer diagnostic never fires for the no comma case.Every valid invocation,
install,install(address = X), andinstall(addr = X), parses correctly, and a single trailing comma is tolerated. The defect only affects the compile error text shown for malformed input, where the macro misattributes the cause.Recommendation
Check for leftover tokens before requiring the comma, so the parser reports the unsupported option directly rather than complaining about a missing separator. Peek for a comma first, treat any other remaining token as an unexpected option, then consume the optional comma and reconfirm the input is empty:
if !input.is_empty() && !input.peek(Token![,]) { return Err(syn::Error::new(input.span(), "unexpected `install` option"));}if !input.is_empty() { input.parse::<Token![,]>()?;}if !input.is_empty() { return Err(syn::Error::new(input.span(), "unexpected `install` option"));}This makes the
unexpected install optionmessage the one the author sees forinstall(address = X extra)while preserving the existing behavior for valid input and a single trailing comma.with_caller uses a redundant manual drop and restores the caller through a panicking borrow
Description
Restoration of the previous caller is already guaranteed by
CallerGuard'sDrop, which runs at end of scope. The explicitlet result = f(); drop(guard); resulttherefore adds nothing. Havinglet _guard = ; f()is equivalent, and only obscures that RAII, not the manualdrop, is what restores the caller.Separately,
CallerGuard::droprestores throughwith_storage, which callsRefCell::borrow_mut()and panics on a conflicting borrow, instead of the fallibletry_with_storageused elsewhere. Ifwith_callerwere called while the storage cell is already borrowed, the restore could panic insideDropduring unwinding and abort the process.Recommendation
Primary fix : keep
CallerGuard, but remove the manualdrop(guard)and bind it as_guardso the guard's existingDroprestores the caller on scope exit:pub fn with_caller<R>(&self, caller: Address, f: impl FnOnce() -> R) -> R { let previous = self.with_storage(|s| s.replace_caller(caller)); let _guard = CallerGuard { storage: *self, previous: Some(previous) }; f()}Optional hardening : replace the body of the existing
CallerGuard::dropso it restores throughtry_borrow_mut. A conflicting borrow then skips the restore instead of panicking insideDrop(which would abort during unwinding):impl Drop for CallerGuard<'_> { fn drop(&mut self) { if let Some(previous) = self.previous.take() { if let Ok(mut guard) = self.storage.storage.try_borrow_mut() { guard.replace_caller(previous); } } }}contract macro diagnostic omits supported address attribute
Summary
The
#[contract]macro accepts bothaddr = ...andaddress = ..., but its unsupported-attribute diagnostic says onlyaddris supported.Finding Description
The
#[contract]attribute macro parses its attribute stream throughContractConfigincrates/common/precompile-macros. The parser accepts an empty attribute list,addr = ..., oraddress = ...; any other identifier is rejected.impl syn::parse::Parse for ContractConfig { fn parse(input: ParseStream<'_>) -> syn::Result<Self> { if input.is_empty() { return Ok(Self { address: None }); } let ident: Ident = input.parse()?; if ident != "addr" && ident != "address" { return Err(syn::Error::new(ident.span(), "only `addr` attribute is supported")); } input.parse::<Token![=]>()?; let address: Expr = input.parse()?; Ok(Self { address: Some(address) }) }}The rejection message is therefore stale: the actual accepted key set includes both
addrandaddress, while the diagnostic says onlyaddris supported. This does not affect generated runtime behavior, but it gives incorrect guidance to developers working with the audited precompile macro. A caller who misspells another key, or who is trying to determine the supported public macro syntax from compiler output, is told thataddressis unsupported even though the parser intentionally accepts it.The public macro entrypoint forwards the parsed
ContractConfigdirectly into contract generation, so the acceptedaddress = ...alias follows the same generation path asaddr = ....#[proc_macro_attribute]pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream { let config = parse_macro_input!(attr as contract::ContractConfig); let input = parse_macro_input!(item as DeriveInput); contract::generate(input, config.address.as_ref())}Recommendation
We recommend updating the diagnostic to describe the full accepted key set, for example:
return Err(syn::Error::new( ident.span(), "only `addr` or `address` attribute is supported",));precompile install diagnostic omits supported addr attribute
Summary
The
#[precompile]macro accepts bothinstall(address = ...)andinstall(addr = ...), but its unsupported-key diagnostic says onlyaddress = ...is supported.Finding Description
The
#[precompile]install-option parser supports two equivalent keys for selecting the installed precompile address. The parser rejects only keys that are neitheraddressnoraddr, so both spellings are part of the public macro syntax:impl Parse for InstallConfig { fn parse(input: ParseStream<'_>) -> syn::Result<Self> { let key: Ident = input.parse()?; if key != "address" && key != "addr" { return Err(syn::Error::new_spanned(key, "`install` supports only `address = ...`")); }The diagnostic is stale because it documents only the long-form key. A precompile author who misspells an install option, or who relies on compiler output to determine the accepted syntax, is told that only
address = ...is supported even thoughaddr = ...is accepted by the same parser.The parsed install address is then used by the generated
install()method, confirming that the acceptedaddralias reaches the user-facing macro generation path rather than being dead parsing logic:let install = config.install.map(|install| { let address = install .address .map_or_else(|| quote! { <#storage>::ADDRESS }, |address| quote! { #address }); quote! { pub fn install( precompiles: &mut ::alloy_evm::precompiles::PrecompilesMap, #(#install_arg_defs),* ) { precompiles.extend_precompiles(::core::iter::once(( #address, Self::precompile(#(#install_arg_names),*), ))); } }});Recommendation
We recommend updating the diagnostic so it documents the full accepted key set, for example:
return Err(syn::Error::new_spanned( key, "`install` supports only `address = ...` or `addr = ...`",));Stablecoin decimals Fallback Returns 0 Instead of the System Default
Description
This non-asset
decimalsbranch, taken only byB20StablecoinStorage, maps aNonefromB20Variant::from_addressto0, the exact sentinel every other decimals path treats as "unset, fall back to6". The0is unreachable today because the stablecoin storage is only ever constructed from an address already validated as a stablecoin B-20 address (bylookup.rsand by the factory'scompute_address), sofrom_addressalways returnsSome(Stablecoin)anddecimals()always yields6. This is therefore not a behavioral bug, but the fallback relies on an invariant enforced two layers away; if the address were ever arbitrary the0would silently leak instead of falling back to the documented default, and a token reportingdecimals == 0is anomalous for an ERC-20-style asset. This is the sibling of Finding 4, which flags the asset path hardcoding6where this one hardcodes0.Recommendation
Replace the
0fallback with the canonical default so it matches the rest of the system and no longer depends on an externally enforced invariant. For example, fall back tocrate::B20Variant::Stablecoin.decimals()so the unreachable arm still produces the documented6rather than the "unset" sentinel.[slot(key)] computes a non ERC 7201 slot and misleads on namespacing
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Description
#[slot("key")]and#[base_slot("key")]compute a plainkeccak256("key"). This is not ERC 7201. It skips the minus 1 step, the second hash, and the final& ~0xffmask. The mask is what matters: ERC 7201 zeroes the low byte so the 256 slots following a root cannot overlap another namespace root. The string form drops that guarantee.The real risk is semantic, not a runtime hash collision. The attribute name implies safe namespacing that it does not provide, so
#[slot("mylabel")]quietly lands on a raw unmasked keccak slot with no structural protection against layout overlap. Base already has the correct mechanism in#[namespace()], backed byerc7201_root.precompile-storage/README.mdmakes it worse by listing#[slot("key")]under "Manual slot override" next to the integer forms, presenting it as an equal blessed option. No Base contract uses the string form, so this is unused but documented surface.Recommendation
Preferred: delete the
Lit::Strarm and the#[slot("key")]README line so#[slot]and#[base_slot]take integers only. All hashed namespacing then goes through#[namespace]. No in tree usage means this is non breaking for Base.Fallback for Tempo parity: keep the arm but document the string form in code and README as a raw non ERC 7201 primitive with no namespace separation, and move the README line out of the override list so it is not presented as equivalent.
Release builds do not enforce generated storage collision checks
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
The generated storage constructor compiles out the only collision-enforcement hook in release builds.
Finding Description
The generated collision checks are all gated behind
debug_assertions:- The generated
__newconstructor callsslots::__check_all_collisions()only whendebug_assertionsare enabled atlayout.rs. - The aggregate collision function is emitted only under
#[cfg(debug_assertions)]atlayout.rs. - Each per-field check is emitted only under
#[cfg(debug_assertions)]atpacking.rs.
The constructor then unconditionally instantiates field handlers from generated slot constants. Release builds therefore trust the generated layout even if overlapping field locations exist.
Relevant code:
fn __new(address: ::alloy_primitives::Address, storage: ::base_precompile_storage::StorageCtx<'a>) -> Self { #[cfg(debug_assertions)] { slots::__check_all_collisions(); } Self { /* field handlers */ address, storage }} #[cfg(debug_assertions)]pub(super) fn __check_all_collisions() { #(#check_fn_calls();)*}Recommendation
We recommend removing the build-profile gate around generated collision checks or converting collision detection into a compile-time macro error before code generation completes.
args() empty-list duplicate is not rejected
Description
Every other option (
id,storage,macro_path,install) rejects duplicates viareject_duplicate(&..., &key), which keys off "has the key been seen."argsinstead guards on!args.is_empty(), which keys off the parsed value. Because an emptyargs()leaves the vec empty, the guard does not fire:args(), args(x: u8)is accepted; the second silently overwrites the first.args(), args()is accepted.
A duplicate is only caught when the first
args()is non-empty, making detection inconsistent with the rest of the parser. This is a malformed-input lint gap a developer hits by hand-writing redundant macro input; generated code remains correct for whatever finalargsvalue wins.Recommendation
Track presence rather than emptiness, matching the
reject_duplicatepattern. ChangeargstoOption<Vec<PrecompileArg>>:"args" => { reject_duplicate(&args, &key)?; let content; parenthesized!(content in input); args = Some( content .parse_terminated(PrecompileArg::parse, Token![,])? .into_iter() .collect(), );}#[contract] silently drops all non-derive struct-level attributes
Description
gen_outputkeeps only#[derive()]and filters out every other struct-level outer attribute. Doc comments,#[cfg],#[cfg_attr],#[allow],#[deprecated], and#[serde]never reach the regeneratedstruct #name<'a>. Thederivesvec is the only attribute set threaded intogen_struct, which also hardcodes its own#[doc]atlayout.rs:129-137, so any user doc comment is replaced by the generic"Storage layout for the [Name] precompile."string.This is live.
PolicyRegistryStorageatstorage.rs:59-64carries a hardfork-safety note that the macro discards. The docSlots are append-only, never reorder across hardforksnever reaches the generated type's rustdoc.The whitelist direction is wrong. Field-level attributes must be dropped because the generated struct has different fields, and the macro helper attributes
contract,namespace, andstorage_namespacemust be stripped. Ordinary struct-level outer attributes belong on the regenerated type, but whitelistingderivedrops them too.Recommendation
Forward all struct-level attributes except the macro helpers, and stop clobbering user docs. Replace the
derivewhitelist with a blacklist of macro helper attributes using the existingattr_path_ishelper atutils.rs:43.let forwarded = input .attrs .iter() .filter(|attr| { !attr.path().is_ident("contract") && !attr_path_is(attr.path(), "namespace") && !attr_path_is(attr.path(), "storage_namespace") }) .cloned() .collect::<Vec<_>>();Then in
gen_structatlayout.rs:129, emit the fallback#[doc = #doc_str]only when the forwarded attrs contain nodocattribute, so a user doc comment is preserved instead of duplicated. Then ingen_structatlayout.rs:129, emit the fallback#[doc = #doc_str]only when the forwarded attrs contain nodocattribute, so a user doc comment is preserved instead of duplicated.Staticcall state-change violations are returned as ordinary reverts
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Summary
Base native precompile storage rejects
sstore,tstore, and event emission while executing in a static context, but the shared error conversion maps those violations to an ordinaryPrecompileOutput::revertwith empty returndata. This makes static-context state-change attempts from native precompiles use revm's normal revert path instead of the exceptional static-call state-change halt used by EVM opcodes.Finding Description
EvmPrecompileStorageProvidercorrectly detects static execution before mutating persistent storage, transient storage, or logs and returnsBasePrecompileError::StaticCallViolation:fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { if self.is_static { return Err(BasePrecompileError::StaticCallViolation); } // ...} fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { if self.is_static { return Err(BasePrecompileError::StaticCallViolation); } // ...} fn emit_event(&mut self, address: Address, event: LogData) -> Result<()> { if self.is_static { return Err(BasePrecompileError::StaticCallViolation); } // ...}However,
BasePrecompileError::into_precompile_resultclassifiesStaticCallViolationas empty revert data and then returnsPrecompileOutput::revert(...):pub fn into_precompile_result(self, gas: u64, state_gas: u64) -> PrecompileResult { let bytes: Bytes = match self { // ... Self::StaticCallViolation => Bytes::new(), // ... }; Ok(PrecompileOutput::revert(gas, bytes, state_gas))}In the pinned revm execution path, a reverted precompile output is converted to
InstructionResult::Revert, while a halted precompile output is converted to a precompile halt result. Opcode-level static-call state-change checks use the halt path instead: revm's static-call guard returnsInstructionResult::StateChangeDuringStaticCall, which converts toHaltReason::StateChangeDuringStaticCall.The broken guarantee is therefore not that the static guard is missing. The guard exists, but it is lowered into the same result class as contract-defined reverts. Static state-change attempts against native precompiles can be observed as an ordinary empty revert instead of the EVM's static-call state-change exceptional halt, including different result classification and gas/returndata handling from the opcode path.
Recommendation
We recommend mapping
BasePrecompileError::StaticCallViolationto a halt/error result instead ofPrecompileOutput::revert. The fix should make static-context mutations from native precompiles follow the same exceptional path as opcode-levelStateChangeDuringStaticCall, and tests should assert the final precompile/interpreter result class rather than only checking that the storage provider returnedStaticCallViolation.# [Appendix] Unit Test Coverage Improvements
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Overview
This appendix documents the unit tests added to improve coverage of the
base-precompile-storageandbase-precompile-macroscrates. Each section lists the source file, a table of the new tests with what they verify and their pass status, followed by the test source code.Result:
base-precompile-macros48 passed / 0 failed,base-precompile-storage148 passed / 0 failed. Every test listed below passed.Crate File New tests base-precompile-storagesrc/evm.rs5 base-precompile-storagesrc/provider.rs6 base-precompile-macrossrc/utils.rs13 base-precompile-macrossrc/accounting.rs5 base-precompile-macrossrc/storable.rs1 base-precompile-macrossrc/contract.rs9 base-precompile-macrossrc/namespace.rs6 Total 7 files 45
crates/common/precompile-storage/src/evm.rsThese tests exercise the production
EvmPrecompileStorageProvideragainst a real revm journal. Prior coverage only exercised theHashMapStorageProvidermirror.Test What is tested Pass state_gas_is_drawn_from_regular_gas_on_new_accountState gas is drawn from regular gas; gas_used()includes both non-state and state-gas components✅ reservoir_is_zero_even_with_nonzero_input_reservoirreservoir()reports0regardless of input reservoir value✅ set_code_fails_closed_when_gas_excludes_state_gasset_codereturnsOutOfGaswhen the budget covers only non-state costs✅ sstore_in_static_context_is_rejectedsstorein a static call returnsStaticCallViolation✅ keccak256_hashes_and_charges_gaskeccak256returns the correct hash and charges base + per-word gas✅ /// On the production provider, the partial-EIP-8037 path draws state gas from/// regular gas: a new-account `set_code` counts the state gas in BOTH/// `state_gas_used()` and the regular `gas_used()` total.#[test]fn state_gas_is_drawn_from_regular_gas_on_new_account() { let mut ctx = ctx(); let gas_params = gas_params(); let code = code(); let len = code.len(); let mut provider = EvmPrecompileStorageProvider::new(make_input(&mut ctx, u64::MAX, 0, false), gas_params.clone()); provider.set_code(ADDR, code).unwrap(); let state_gas = gas_params.create_state_gas() + gas_params.code_deposit_state_gas(len); assert!(state_gas > 0, "AMSTERDAM state gas must be non-zero"); assert_eq!(provider.state_gas_used(), state_gas); assert_eq!(provider.gas_used(), non_state_regular(&gas_params, len) + state_gas); assert!(provider.gas_used() > provider.state_gas_used());} /// The provider reports pre-fork reservoir semantics: `reservoir()` is always 0,/// even with a non-zero input reservoir and after state gas is charged.#[test]fn reservoir_is_zero_even_with_nonzero_input_reservoir() { let mut ctx = ctx(); let mut provider = EvmPrecompileStorageProvider::new(make_input(&mut ctx, u64::MAX, 5_000, false), gas_params()); assert_eq!(provider.reservoir(), 0, "input reservoir must be ignored (pre-fork = 0)"); provider.set_code(ADDR, code()).unwrap(); assert_eq!(provider.reservoir(), 0, "charging state gas must not create a reservoir");} /// Because state gas is drawn from regular gas, a budget that only covers the/// non-state costs makes `set_code` fail closed on the state-gas charge.#[test]fn set_code_fails_closed_when_gas_excludes_state_gas() { let mut ctx = ctx(); let gas_params = gas_params(); let code = code(); let budget = non_state_regular(&gas_params, code.len()); let mut provider = EvmPrecompileStorageProvider::new(make_input(&mut ctx, budget, 0, false), gas_params); let result = provider.set_code(ADDR, code); assert!(matches!(result, Err(BasePrecompileError::OutOfGas)));} /// `sstore` in a static call context must revert with `StaticCallViolation`/// before touching the journal.#[test]fn sstore_in_static_context_is_rejected() { let mut ctx = ctx(); let mut provider = EvmPrecompileStorageProvider::new(make_input(&mut ctx, u64::MAX, 0, true), gas_params()); let result = provider.sstore(ADDR, U256::from(1), U256::from(2)); assert!(matches!(result, Err(BasePrecompileError::StaticCallViolation)));} /// `keccak256` returns the correct hash and charges base + per-word gas.#[test]fn keccak256_hashes_and_charges_gas() { let mut ctx = ctx(); let mut provider = EvmPrecompileStorageProvider::new(make_input(&mut ctx, u64::MAX, 0, false), gas_params()); let data = b"base precompile keccak input spanning two words!!"; let before = provider.gas_used(); let hash = provider.keccak256(data).unwrap(); assert_eq!(hash, keccak256(data)); let num_words = data.len().div_ceil(32) as u64; assert_eq!(provider.gas_used() - before, KECCAK256 + KECCAK256WORD * num_words);}
crates/common/precompile-storage/src/provider.rsA new test module covering the storage-trait core logic (the file previously had no tests).
Test What is tested Pass layout_is_packableLayout::Bytes/Layout::Slotspackability boundaries✅ layout_slots_and_bytesSlot and byte count math for layout types ✅ layout_ctx_packed_offsetLayoutCtx::FULLvspacked(n)offset behavior✅ mapping_slot_matches_solidity_derivationStorageKey::mapping_slotmatches Soliditykeccak256(lpad32(key) ‖ slot)✅ packable_full_roundtrip_and_deleteBlanket Storable for Packable: full-slot store/load/delete✅ packable_packed_delete_preserves_neighbor_bytePacked delete clears only its field; an adjacent byte is preserved ✅ #[test]fn layout_is_packable() { assert!(Layout::Bytes(1).is_packable()); assert!(Layout::Bytes(31).is_packable()); assert!(!Layout::Bytes(32).is_packable()); assert!(!Layout::Slots(1).is_packable());} #[test]fn layout_slots_and_bytes() { assert_eq!(Layout::Bytes(20).slots(), 1); assert_eq!(Layout::Bytes(20).bytes(), 20); assert_eq!(Layout::Slots(3).slots(), 3); assert_eq!(Layout::Slots(3).bytes(), 96);} #[test]fn layout_ctx_packed_offset() { assert_eq!(LayoutCtx::FULL.packed_offset(), None); assert_eq!(LayoutCtx::packed(0).packed_offset(), Some(0)); assert_eq!(LayoutCtx::packed(31).packed_offset(), Some(31));} #[test]fn mapping_slot_matches_solidity_derivation() { let key = Address::repeat_byte(0x11); let slot = U256::from(7u64); let mut buf = [0u8; 64]; buf[12..32].copy_from_slice(key.as_slice()); buf[32..].copy_from_slice(&slot.to_be_bytes::<32>()); let expected = U256::from_be_bytes(keccak256(buf).0); assert_eq!(key.mapping_slot(slot), expected);} /// Blanket `Storable for Packable`: a full-slot value round-trips and `delete`/// zeroes the whole word.#[test]fn packable_full_roundtrip_and_delete() { let mut slot = PackedSlot(U256::ZERO); let addr = Address::repeat_byte(0xAB); <Address as Storable>::store(&addr, &mut slot, U256::ZERO, LayoutCtx::FULL).unwrap(); assert_eq!(<Address as Storable>::load(&slot, U256::ZERO, LayoutCtx::FULL).unwrap(), addr); <Address as Storable>::delete(&mut slot, U256::ZERO, LayoutCtx::FULL).unwrap(); assert_eq!(slot.0, U256::ZERO);} /// Blanket `Storable for Packable` at a byte offset: writing/deleting a packed/// primitive must not disturb a neighbor sharing the same word.#[test]fn packable_packed_delete_preserves_neighbor_byte() { // Low byte holds a sentinel owned by a notional neighbor field. let mut slot = PackedSlot(U256::from(0xFFu64)); <bool as Storable>::store(&true, &mut slot, U256::ZERO, LayoutCtx::packed(1)).unwrap(); assert!(<bool as Storable>::load(&slot, U256::ZERO, LayoutCtx::packed(1)).unwrap()); assert_eq!(slot.0 & U256::from(0xFFu64), U256::from(0xFFu64), "neighbor untouched by store"); <bool as Storable>::delete(&mut slot, U256::ZERO, LayoutCtx::packed(1)).unwrap(); assert!(!<bool as Storable>::load(&slot, U256::ZERO, LayoutCtx::packed(1)).unwrap()); assert_eq!(slot.0 & U256::from(0xFFu64), U256::from(0xFFu64), "neighbor untouched by delete");}
crates/common/precompile-macros/src/utils.rsCoverage for slot, namespace, and array-size attribute parsing — areas that had no prior coverage.
Test What is tested Pass extract_attributes_parses_decimal_and_hex_slots#[slot(42)]and#[slot(0x2a)]resolve to the same value✅ extract_attributes_hashes_string_slot#[slot("foo")]resolves tokeccak256(b"foo")✅ extract_attributes_reads_base_slot#[base_slot(7)]parsed correctly✅ extract_attributes_rejects_duplicate_slotDuplicate #[slot]attributes error✅ extract_attributes_rejects_combined_slot_and_base_slotslot+base_sloton the same item errors✅ extract_attributes_rejects_combined_slot_and_namespaceslot+namespaceon the same item errors✅ array_sizes_accepts_valid_range#[storable_arrays(1, 256)]accepted✅ array_sizes_reject_zeroSize 0rejected✅ array_sizes_reject_over_maxSize 257rejected✅ array_sizes_reject_duplicatesDuplicate sizes rejected ✅ array_sizes_reject_non_integerNon-integer size literals rejected ✅ erc7201_root_masks_low_byte_to_zeroERC-7201 namespace root has its low byte zeroed ✅ extract_namespace_rejects_duplicateDuplicate #[namespace]attributes error✅ #[test]fn extract_attributes_parses_decimal_and_hex_slots() { let decimal: Vec<Attribute> = vec![parse_quote!(#[slot(42)])]; let hex: Vec<Attribute> = vec![parse_quote!(#[slot(0x2a)])]; assert_eq!(extract_attributes(&decimal).unwrap().0, Some(U256::from(42))); assert_eq!(extract_attributes(&hex).unwrap().0, Some(U256::from(42)));} #[test]fn extract_attributes_hashes_string_slot() { let attrs: Vec<Attribute> = vec![parse_quote!(#[slot("foo")])]; let expected: U256 = keccak256(b"foo").into(); assert_eq!(extract_attributes(&attrs).unwrap().0, Some(expected));} #[test]fn extract_attributes_reads_base_slot() { let attrs: Vec<Attribute> = vec![parse_quote!(#[base_slot(7)])]; assert_eq!(extract_attributes(&attrs).unwrap().1, Some(U256::from(7)));} #[test]fn extract_attributes_rejects_duplicate_slot() { let attrs: Vec<Attribute> = vec![parse_quote!(#[slot(1)]), parse_quote!(#[slot(2)])]; assert!(extract_attributes(&attrs).is_err());} #[test]fn extract_attributes_rejects_combined_slot_and_base_slot() { let attrs: Vec<Attribute> = vec![parse_quote!(#[slot(1)]), parse_quote!(#[base_slot(2)])]; assert!(extract_attributes(&attrs).is_err());} #[test]fn extract_attributes_rejects_combined_slot_and_namespace() { let attrs: Vec<Attribute> = vec![parse_quote!(#[slot(1)]), parse_quote!(#[namespace("b20.policy")])]; assert!(extract_attributes(&attrs).is_err());} #[test]fn array_sizes_accepts_valid_range() { let attrs: Vec<Attribute> = vec![parse_quote!(#[storable_arrays(1, 256)])]; assert_eq!(extract_storable_array_sizes(&attrs).unwrap(), Some(vec![1, 256]));} #[test]fn array_sizes_reject_zero() { let attrs: Vec<Attribute> = vec![parse_quote!(#[storable_arrays(0)])]; assert!(extract_storable_array_sizes(&attrs).is_err());} #[test]fn array_sizes_reject_over_max() { let attrs: Vec<Attribute> = vec![parse_quote!(#[storable_arrays(257)])]; assert!(extract_storable_array_sizes(&attrs).is_err());} #[test]fn array_sizes_reject_duplicates() { let attrs: Vec<Attribute> = vec![parse_quote!(#[storable_arrays(4, 4)])]; assert!(extract_storable_array_sizes(&attrs).is_err());} #[test]fn array_sizes_reject_non_integer() { let attrs: Vec<Attribute> = vec![parse_quote!(#[storable_arrays("4")])]; assert!(extract_storable_array_sizes(&attrs).is_err());} #[test]fn erc7201_root_masks_low_byte_to_zero() { let id: LitStr = parse_quote!("base.token.storage"); let root = erc7201_root(&id).unwrap(); assert_eq!(root & U256::from(0xffu64), U256::ZERO);} #[test]fn extract_namespace_rejects_duplicate() { let attrs: Vec<Attribute> = vec![parse_quote!(#[namespace("a")]), parse_quote!(#[namespace("b")])]; assert!(extract_namespace(&attrs).is_err());}
crates/common/precompile-macros/src/accounting.rsA new test module for the ABI field-detection helpers used by generated accounting code (the file previously had no tests).
Test What is tested Pass has_field_detects_named_fieldDetects a named struct field ✅ has_field_false_for_tuple_structReturns false for tuple structs ✅ has_field_false_for_non_structReturns false for non-struct types ✅ require_field_errors_when_missingErrors when a required field is absent ✅ require_field_ok_when_presentSucceeds when a required field is present ✅ #[test]fn has_field_detects_named_field() { let input: DeriveInput = parse_quote! { struct Storage<'a> { b20: B20<'a>, asset: Asset<'a> } }; assert!(has_field(&input, "b20")); assert!(has_field(&input, "asset")); assert!(!has_field(&input, "stablecoin"));} #[test]fn has_field_false_for_tuple_struct() { let input: DeriveInput = parse_quote! { struct Storage(u8); }; assert!(!has_field(&input, "b20"));} #[test]fn has_field_false_for_non_struct() { let input: DeriveInput = parse_quote! { enum Storage { A } }; assert!(!has_field(&input, "b20"));} #[test]fn require_field_errors_when_missing() { let input: DeriveInput = parse_quote! { struct Storage { other: u8 } }; let err = require_field(&input, "b20").unwrap_err(); assert!(err.to_string().contains("missing `b20` field"));} #[test]fn require_field_ok_when_present() { let input: DeriveInput = parse_quote! { struct Storage<'a> { b20: B20<'a> } }; assert!(require_field(&input, "b20").is_ok());}
crates/common/precompile-macros/src/storable.rsTest What is tested Pass validate_sequential_discriminants_rejects_too_many_variantsEnums with more than 256 variants are rejected ✅ #[test]fn validate_sequential_discriminants_rejects_too_many_variants() { let variants = (0..=256).map(|index| format!("V{index}")).collect::<Vec<_>>().join(", "); let input: DeriveInput = syn::parse_str(&format!("enum TooMany {{ {variants} }}")).unwrap(); let data_enum = parse_enum(input); let err = validate_sequential_discriminants(&data_enum).unwrap_err(); assert!(err.to_string().contains("at most 256 variants"));}
crates/common/precompile-macros/src/contract.rsA new test module for the
#[contract]macro front-end parsing (the file previously had no tests).Test What is tested Pass config_empty_has_no_addressAn empty config yields no address ✅ config_accepts_addr_and_addressBoth addr =andaddress =are accepted✅ config_rejects_unknown_keyUnknown config keys error ✅ parse_fields_accepts_named_fieldsNamed struct fields are parsed in order ✅ parse_fields_rejects_reserved_nameReserved names ( address,storage,msg_sender) are rejected✅ parse_fields_rejects_genericsGeneric struct parameters are rejected ✅ parse_fields_rejects_tuple_structTuple structs are rejected ✅ parse_fields_rejects_enumEnums are rejected ✅ parse_fields_rejects_field_attrs_with_contract_namespaceField-level #[slot]combined with a contract-level#[namespace]is rejected✅ #[test]fn config_empty_has_no_address() { assert!(parse_config(quote! {}).unwrap().address.is_none());} #[test]fn config_accepts_addr_and_address() { assert!(parse_config(quote! { addr = SOME_ADDR }).unwrap().address.is_some()); assert!(parse_config(quote! { address = SOME_ADDR }).unwrap().address.is_some());} #[test]fn config_rejects_unknown_key() { let err = parse_config(quote! { foo = 1 }).err().unwrap(); assert!(err.to_string().contains("only `addr` attribute is supported"));} #[test]fn parse_fields_accepts_named_fields() { let input: DeriveInput = parse_quote! { struct Token { owner: Address, total_supply: U256 } }; let fields = parse_fields(input, false).unwrap(); assert_eq!(fields.len(), 2); assert_eq!(fields[0].name, "owner"); assert_eq!(fields[1].name, "total_supply");} #[test]fn parse_fields_rejects_reserved_name() { for reserved in ["address", "storage", "msg_sender"] { let field: syn::Ident = syn::parse_str(reserved).unwrap(); let input: DeriveInput = parse_quote! { struct Token { #field: U256 } }; let err = parse_fields(input, false).unwrap_err(); assert!(err.to_string().contains("reserved")); }} #[test]fn parse_fields_rejects_generics() { let input: DeriveInput = parse_quote! { struct Token<T> { value: T } }; let err = parse_fields(input, false).unwrap_err(); assert!(err.to_string().contains("generic"));} #[test]fn parse_fields_rejects_tuple_struct() { let input: DeriveInput = parse_quote! { struct Token(U256); }; assert!(parse_fields(input, false).is_err());} #[test]fn parse_fields_rejects_enum() { let input: DeriveInput = parse_quote! { enum Token { A } }; assert!(parse_fields(input, false).is_err());} #[test]fn parse_fields_rejects_field_attrs_with_contract_namespace() { let input: DeriveInput = parse_quote! { struct Token { #[slot(1)] value: U256, } }; let err = parse_fields(input, true).unwrap_err(); assert!(err.to_string().contains("contract-level `namespace`"));}
crates/common/precompile-macros/src/namespace.rsA new test module for the
#[namespace]attribute macro validation (the file previously had no tests).Test What is tested Pass applies_to_contract_structExpands on a #[contract]struct✅ applies_to_storable_structExpands on a #[derive(Storable)]struct✅ rejects_struct_without_contract_or_storableA bare struct without pairing errors ✅ rejects_existing_namespace_attributeA duplicate #[namespace]on the item errors✅ rejects_invalid_namespace_idWhitespace in the namespace id errors ✅ has_storable_derive_detects_storableDetects Storablein the derive list✅ #[test]fn applies_to_contract_struct() { let attr = quote! { "b20.policy" }; let item = quote! { #[contract(addr = ADDR)] struct S { value: U256 } }; assert!(expand_impl(attr, item).is_ok());} #[test]fn applies_to_storable_struct() { let attr = quote! { "b20.policy" }; let item = quote! { #[derive(Storable)] struct S { value: U256 } }; assert!(expand_impl(attr, item).is_ok());} #[test]fn rejects_struct_without_contract_or_storable() { let attr = quote! { "b20.policy" }; let item = quote! { struct S { value: U256 } }; let err = expand_impl(attr, item).unwrap_err(); assert!(err.to_string().contains("must be paired"));} #[test]fn rejects_existing_namespace_attribute() { let attr = quote! { "b20.policy" }; let item = quote! { #[contract(addr = ADDR)] #[namespace("b20.policy")] struct S { value: U256 } }; let err = expand_impl(attr, item).unwrap_err(); assert!(err.to_string().contains("duplicate"));} #[test]fn rejects_invalid_namespace_id() { let attr = quote! { "has whitespace" }; let item = quote! { #[contract(addr = ADDR)] struct S { value: U256 } }; assert!(expand_impl(attr, item).is_err());} #[test]fn has_storable_derive_detects_storable() { let with: DeriveInput = parse_quote! { #[derive(Debug, Storable)] struct S { value: U256 } }; let without: DeriveInput = parse_quote! { #[derive(Debug, Clone)] struct S { value: U256 } }; assert!(has_storable_derive(&with).unwrap()); assert!(!has_storable_derive(&without).unwrap());}