|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +published: true |
| 4 | +title: 'Transient Storage Clearing Helper Collision Bug' |
| 5 | +date: '2026-02-18' |
| 6 | +author: Solidity Team |
| 7 | +category: Security Alerts |
| 8 | +--- |
| 9 | + |
| 10 | +On 2026-02-11, a bug in the Solidity code generator was reported by [Hexens](https://hexens.io/). |
| 11 | +The bug affects compiler versions 0.8.28 through 0.8.33 when using the IR pipeline. |
| 12 | +When a contract clears both a persistent and a transient storage variable of the same type, the compiler will emit the wrong opcode (`sstore` instead of `tstore`, or vice versa) for one of these operations, because the generated Yul helper functions share the same name and one overwrites the other. |
| 13 | + |
| 14 | +We assign this bug a severity of _high_ on our internal scale. |
| 15 | +Only three deployed contracts matching the affected pattern have been identified across all EVM-compatible chains. |
| 16 | +The respective teams have been notified and measures have been taken to mitigate potential impact. |
| 17 | +Any contract compiled with `--via-ir` that uses `delete` on transient storage should be reviewed. |
| 18 | + |
| 19 | +The bug only affects the IR pipeline; the legacy evmasm pipeline is not affected. |
| 20 | + |
| 21 | +The `--via-ir` flag is not enabled by default, so projects that have not explicitly opted into it are not affected. |
| 22 | + |
| 23 | +The bug is fixed in Solidity 0.8.34. |
| 24 | +All versions from 0.8.28 through 0.8.33 are affected. |
| 25 | + |
| 26 | +## Which Contracts Are Affected? |
| 27 | + |
| 28 | +A contract is only affected if **all** of the following conditions are met: |
| 29 | + |
| 30 | +1. It is compiled with `--via-ir` (or `settings.viaIR` in Standard JSON). |
| 31 | +2. It uses [`delete`](https://docs.soliditylang.org/en/v0.8.34/types.html#delete) on a [transient](https://docs.soliditylang.org/en/v0.8.34/contracts.html#transient-storage) state variable. |
| 32 | +3. The same compilation unit (the contract and its inherited contracts) also contains a clearing operation on persistent storage that involves a matching value type (see details below). |
| 33 | + |
| 34 | +**If your code does not use `delete` on a transient state variable, your contract is not affected.** |
| 35 | + |
| 36 | +Note that explicitly assigning the zero value (e.g., `_lock = 0`) does **not** involve the use of the clearing helper and therefore does not trigger the bug. |
| 37 | + |
| 38 | +### How types are matched |
| 39 | + |
| 40 | +The collision occurs when two clearing operations - one transient, one persistent - produce the same internal helper function. |
| 41 | +This happens when both operations clear the same Solidity value type. |
| 42 | +The persistent variable does not need to be declared as the same type as the transient variable; it is sufficient for a matching value type to be nested within it, for example as a struct member or array element. |
| 43 | + |
| 44 | +### The transient side |
| 45 | + |
| 46 | +There is exactly one code path that produces the `tstore` variant: a `delete` on a transient state variable. |
| 47 | +Since transient arrays, mappings, and structs are not yet supported in Solidity, this is always a `delete` on a value type. |
| 48 | + |
| 49 | +### The persistent side |
| 50 | + |
| 51 | +Any operation that clears persistent storage of a matching value type can produce the `sstore` variant: |
| 52 | + |
| 53 | +- **Direct deletes**: `delete <value-type>`. |
| 54 | + This includes values nested in more complex types, e.g. `delete mapping[key]` or `delete array[index]` where the element is a value type. |
| 55 | +- **Array shrinking**: `.pop()`, `delete <array>`, shrinking dynamic arrays by assigning a shorter memory/calldata array, assigning `new T[](0)` |
| 56 | +- **Struct clearing**: `delete <struct>` (recurses into each member) |
| 57 | + |
| 58 | +### Cross-type collisions via array clearing |
| 59 | + |
| 60 | +Clearing operations on arrays are generally performed at slot granularity, treating every slot as `uint256` rather than clearing each element individually. |
| 61 | +This means operations such as `delete <bool[] var>`, `delete <address[5] var>`, or `delete <uint8[10][20] var>` all use the same clearing helper as `delete <uint256 var>`, regardless of the actual element type. |
| 62 | +A contract with `bool[] _flags` and `uint256 transient _temp` will therefore have a collision between array clearing and transient deletion, even though the declared types are `bool` and `uint256`. |
| 63 | + |
| 64 | +This slot-granularity expansion does **not** apply to `.pop()` (which uses the actual element type) or to struct member clearing for members smaller than 32 bytes (which uses a direct `sstore(slot, 0)` that bypasses the shared helper entirely). |
| 65 | + |
| 66 | +### Inheritance and external helpers |
| 67 | + |
| 68 | +The two sides of the collision do not need to be in the same contract. |
| 69 | +A base contract containing a transient variable and a derived contract with a persistent variable or mapping with a value of the same type are sufficient to trigger the bug. |
| 70 | +Similarly, clearing operations in free functions or library functions used by the contract can also contribute to the collision. |
| 71 | + |
| 72 | +### Creation code vs deployed code |
| 73 | + |
| 74 | +Both clearing operations must be present within the same Yul object. |
| 75 | +A `delete` that appears only in creation code (e.g., inside a constructor) and a `delete` that appears only in the deployed runtime code do not interact, because the compiler generates these as separate Yul objects with independent helper function sets. |
| 76 | + |
| 77 | +## The Bug |
| 78 | + |
| 79 | +The IR pipeline generates reusable Yul helper functions during code generation. |
| 80 | +These helpers are deduplicated by name: there can only be one Yul function with a given name, so the name must fully encode the function's behavior. |
| 81 | + |
| 82 | +The helper responsible for clearing a storage slot (used by `delete` and related operations) derives its name from the Solidity type being cleared - for example, `storage_set_to_zero_t_address` for type `address` in storage. |
| 83 | +However, the name does not include the storage kind: persistent and transient storage produce the same function name for the same type. |
| 84 | + |
| 85 | +Since there can only be one function with this name, whichever clearing operation the compiler encounters first determines the implementation. |
| 86 | +If a persistent clearing operation is encountered first, the generated function uses `sstore`. |
| 87 | +If a transient `delete` is encountered first, it uses `tstore`. |
| 88 | +The second operation reuses the existing function with the wrong opcode. |
| 89 | + |
| 90 | +Which clearing operation is encountered first depends on the order in which the compiler processes function bodies and the statements within them, which is determined primarily by the order of function selectors and the structure of the syntax tree. |
| 91 | +This ordering is not affected by the use of the optimizer, since it is a distinct stage of the pipeline, performed after code generation. |
| 92 | + |
| 93 | +The fix introduces proper distinction between data locations in the clearing helper's function name. |
| 94 | +This produces two distinct Yul functions - `storage_set_to_zero_t_address` for persistent and `transient_storage_set_to_zero_t_address` for transient - ensuring each uses the correct opcode. |
| 95 | + |
| 96 | +## Examples |
| 97 | + |
| 98 | +The following simplified contracts demonstrate the bug in both directions. |
| 99 | + |
| 100 | +### Example 1: Persistent Clearing Operation Encountered First |
| 101 | + |
| 102 | +In this contract, the persistent `delete delegates[id]` is processed first and generates `storage_set_to_zero_t_address` with `sstore`. |
| 103 | +When `delete _lock` (a transient variable) is subsequently compiled, it reuses that same function, emitting `sstore` to persistent slot 0 instead of generating a new one for `tstore`. |
| 104 | +This overwrites the `owner` state variable with zero. |
| 105 | + |
| 106 | +```solidity |
| 107 | +contract OverwriteStorage { |
| 108 | + // ---- persistent storage ---- |
| 109 | + address public owner; |
| 110 | + mapping(uint256 => address) public delegates; |
| 111 | +
|
| 112 | + // ---- transient storage ---- |
| 113 | + address transient _lock; |
| 114 | +
|
| 115 | + constructor() { owner = msg.sender; } |
| 116 | +
|
| 117 | + function clearDelegate(uint256 id) external { |
| 118 | + delete delegates[id]; |
| 119 | + } |
| 120 | +
|
| 121 | + function guarded() external { |
| 122 | + require(_lock == address(0), "locked"); |
| 123 | + _lock = msg.sender; |
| 124 | + // ... protected logic ... |
| 125 | + delete _lock; |
| 126 | + } |
| 127 | +} |
| 128 | +``` |
| 129 | + |
| 130 | +**Observed behavior:** |
| 131 | + |
| 132 | +```solidity |
| 133 | +OverwriteStorage target = new OverwriteStorage(); |
| 134 | +assert(target.owner() == address(this)); // owner is deployer |
| 135 | +
|
| 136 | +target.guarded(); |
| 137 | +assert(target.owner() == address(0)); // owner overwritten with zero |
| 138 | +``` |
| 139 | + |
| 140 | +An additional consequence: since `delete _lock` writes to persistent storage instead of clearing transient storage, the transient lock value is never released. |
| 141 | +`guarded()` becomes unusable within the same transaction after the first call. |
| 142 | + |
| 143 | +### Example 2: Transient Delete Encountered First |
| 144 | + |
| 145 | +In this contract, the transient `delete _caller` is processed first and generates `storage_set_to_zero_t_address` with `tstore`. |
| 146 | +When `delete approvals[id]` (a persistent mapping value) is subsequently compiled, it reuses that same function, emitting `tstore` instead of `sstore`. |
| 147 | +Rather than removing the approval from persistent storage, the operation clears the corresponding slot in transient storage so the value remains in the mapping. |
| 148 | + |
| 149 | +```solidity |
| 150 | +contract OverwriteTransient { |
| 151 | + // ---- persistent storage ---- |
| 152 | + mapping(uint256 => address) public approvals; |
| 153 | +
|
| 154 | + // ---- transient storage ---- |
| 155 | + address transient _caller; |
| 156 | +
|
| 157 | + function approve(address spender, uint256 id) external { |
| 158 | + approvals[id] = spender; |
| 159 | + } |
| 160 | +
|
| 161 | + function run(bytes calldata) external { |
| 162 | + require(_caller == address(0), "reentrant"); |
| 163 | + _caller = msg.sender; |
| 164 | + // ... callback logic ... |
| 165 | + delete _caller; |
| 166 | + } |
| 167 | +
|
| 168 | + function revokeApproval(uint256 id) external { |
| 169 | + delete approvals[id]; |
| 170 | + } |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +**Observed behavior:** |
| 175 | + |
| 176 | +```solidity |
| 177 | +OverwriteTransient target = new OverwriteTransient(); |
| 178 | +target.approve(spender, 42); |
| 179 | +assert(target.approvals(42) == spender); // spender approved |
| 180 | +
|
| 181 | +target.revokeApproval(42); |
| 182 | +assert(target.approvals(42) == spender); // approval not removed from persistent storage |
| 183 | +``` |
| 184 | + |
| 185 | +Once set, the approval cannot be removed through the `delete` operation. |
| 186 | + |
| 187 | +## Severity Assessment |
| 188 | + |
| 189 | +The compiler emits no warning, and the generated code does not revert at runtime. |
| 190 | +The incorrect storage operations manifest as unexpected state changes rather than failures, which can make the bug difficult to diagnose. |
| 191 | +However, projects that run their test suite with `--via-ir` before deployment are likely to detect incorrect behavior, even if the root cause may not be immediately obvious. |
| 192 | + |
| 193 | +Unlike many past compiler bugs, this one can be triggered without the use of inline assembly, which increases the likelihood of it occurring in practice. |
| 194 | + |
| 195 | +The impact depends on the direction of the collision: |
| 196 | + |
| 197 | +- **Transient `delete` uses `sstore` instead of `tstore`**: |
| 198 | + - Unintended write to persistent storage - the operation writes zero to a persistent slot, most commonly slot 0, which frequently holds `owner`, `_initialized`, or similar access-control variables. |
| 199 | + - Transient variable not cleared - the transient value remains set, which can cause subsequent reads to return stale values (e.g., a reentrancy lock that remains set for the rest of the transaction). |
| 200 | + |
| 201 | +- **Persistent clearing operation uses `tstore` instead of `sstore`**: |
| 202 | + - Ineffective clearing of persistent state - the persistent value remains unchanged, so approvals, mappings, or other state managed through clearing operations are not removed as expected. |
| 203 | + - Unintended write to transient storage - the zero value is written to a transient slot instead, which is discarded at the end of the transaction. |
| 204 | + |
| 205 | +In practice, the affected pattern has proven to be very uncommon. |
| 206 | + |
| 207 | +## Reaction and Precautions |
| 208 | + |
| 209 | +Following the report, a coordinated effort was undertaken to identify deployed contracts matching the affected pattern across all EVM-compatible chains. |
| 210 | +This effort was carried out with the support of SEAL 911, Dedaub, and Hexens. |
| 211 | +Three affected contracts were identified, and the respective teams were notified. |
| 212 | +None of the affected contracts were part of public applications running in production with external user interactions, and the funds at risk were insignificant. |
| 213 | + |
| 214 | +Additionally, the Solidity team verified that no other helper functions in the compiler suffer from the same kind of naming collision. |
| 215 | +A refactoring is planned to make this class of issue harder to introduce in the future. |
| 216 | + |
| 217 | +### How to determine if your project is affected |
| 218 | + |
| 219 | +If your project meets the conditions described in the "Which Contracts Are Affected?" section above, you can confirm the bug by comparing the unoptimized Yul output (produced with the `--ir` flag) between your current compiler version and 0.8.34. |
| 220 | + |
| 221 | +In an affected contract, a single helper handles both persistent and transient clearing for a given type. |
| 222 | +For example, `storage_set_to_zero_t_uint256` delegates to `update_storage_value_t_uint256_to_t_uint256` (which uses `sstore`) for both: |
| 223 | + |
| 224 | +```yul |
| 225 | +function storage_set_to_zero_t_uint256(slot, offset) { |
| 226 | + let zero_0 := zero_value_for_split_t_uint256() |
| 227 | + update_storage_value_t_uint256_to_t_uint256(slot, offset, zero_0) |
| 228 | +} |
| 229 | +``` |
| 230 | + |
| 231 | +Recompiling with 0.8.34 produces a separate transient helper that correctly delegates to `tstore`: |
| 232 | + |
| 233 | +```yul |
| 234 | +function transient_storage_set_to_zero_t_uint256(slot, offset) { |
| 235 | + let zero_0 := zero_value_for_split_t_uint256() |
| 236 | + update_transient_storage_value_t_uint256_to_t_uint256(slot, offset, zero_0) |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +If diffing the two outputs shows `storage_set_to_zero_` call sites changing to `transient_storage_set_to_zero_`, the contract was affected. |
| 241 | + |
| 242 | +### Recommended actions |
| 243 | + |
| 244 | +- Projects currently deployed with `--via-ir` that use transient storage should check whether they are affected using the guidance above. |
| 245 | +- Projects planning to deploy with `--via-ir` should update to Solidity 0.8.34 or later before deployment, particularly if transient storage is used. |
| 246 | +- As an interim workaround, assigning the zero value directly (e.g., `_lock = 0`) instead of using `delete` on transient variables avoids the affected code path. The code path used for assignment correctly distinguishes between persistent and transient storage. |
| 247 | + |
| 248 | +## Technical Details |
| 249 | + |
| 250 | +This section describes the compiler internals behind the bug for readers interested in the implementation-level root cause. |
| 251 | + |
| 252 | +### Yul function deduplication |
| 253 | + |
| 254 | +The IR pipeline deduplicates reusable Yul helper functions through `MultiUseYulFunctionCollector`. |
| 255 | +The collector indexes functions by name: `createFunction(name, creator)` runs the creator callback on the first call for a given name, and returns the cached result on subsequent calls. |
| 256 | + |
| 257 | +```cpp |
| 258 | +// libsolidity/codegen/MultiUseYulFunctionCollector.cpp |
| 259 | +std::string MultiUseYulFunctionCollector::createFunction(std::string const& _name, std::function<std::string()> const& _creator) |
| 260 | +{ |
| 261 | + if (!m_requestedFunctions.count(_name)) // first call only |
| 262 | + { |
| 263 | + m_requestedFunctions.insert(_name); |
| 264 | + std::string fun = _creator(); // generator runs once |
| 265 | + // ... (assertions omitted) |
| 266 | + m_code += std::move(fun); |
| 267 | + } |
| 268 | + return _name; // all calls return same name |
| 269 | +} |
| 270 | +``` |
| 271 | +
|
| 272 | +The correctness of this scheme depends on the function name being a complete key for the generated behavior: two calls that would produce different function bodies must use different names. |
| 273 | +
|
| 274 | +### The name collision in `storageSetToZeroFunction` |
| 275 | +
|
| 276 | +`storageSetToZeroFunction` generates the Yul helper that clears a storage slot. |
| 277 | +It accepts a storage location parameter and uses it to select the correct opcode (`sstore` or `tstore`), but the function name is derived from the type alone: |
| 278 | +
|
| 279 | +```cpp |
| 280 | +// libsolidity/codegen/YulUtilFunctions.cpp |
| 281 | +std::string YulUtilFunctions::storageSetToZeroFunction(Type const& _type, VariableDeclaration::Location _location) |
| 282 | +{ |
| 283 | + std::string const functionName = "storage_set_to_zero_" + _type.identifier(); |
| 284 | + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 285 | + // _location is NOT part of the key |
| 286 | +
|
| 287 | + return m_functionCollector.createFunction(functionName, [&]() { |
| 288 | + // ... |
| 289 | + ("store", updateStorageValueFunction(_type, _type, _location)) |
| 290 | + // sstore or tstore depends on _location |
| 291 | + // but only evaluated on FIRST call for this type |
| 292 | + }); |
| 293 | +} |
| 294 | +``` |
| 295 | + |
| 296 | +### The fix implementation |
| 297 | + |
| 298 | +The fix adds the storage location to the function name: |
| 299 | + |
| 300 | +```cpp |
| 301 | +std::string const functionName = |
| 302 | + (_location == VariableDeclaration::Location::Transient ? "transient_"s : "") + |
| 303 | + "storage_set_to_zero_" + |
| 304 | + _type.identifier(); |
| 305 | +``` |
| 306 | + |
| 307 | +## Acknowledgements |
| 308 | + |
| 309 | +We would like to thank [Hexens](https://hexens.io/) for discovering and reporting this bug with thorough analysis and clear reproduction cases. |
| 310 | +Much of the technical detail in this post is based on their report. |
| 311 | +Hexens have also published [their own analysis of the bug](https://hexens.io/research/solidity-compiler-bug-tstore-poison). |
| 312 | +We would also like to thank [SEAL 911](https://securityalliance.org/our-work/seal-911) for their swift response and support in scanning all EVM-compatible chains for affected contracts, and [Dedaub](https://dedaub.com/) for their assistance in the identification effort. |
0 commit comments