How to shim transient storage (with view functions)
tldr: Because the warm/cold state of storage slots lasts only a single transaction, that state can be used as a scratchpad to implement transient storage.
You can directly check out this repo
Here are some things I thought I knew about the EVM/Solidity:
- You can check whether you’ve been called twice in a single block, but you cannot check whether you’ve been called twice in a single transaction.
- You can’t have transient storage (storage that persists during a transaction only) without EIP-1153.
- You certainly can’t have implement transient storage using only
view
functions.
Well, all the above is actually false. Yes! But! Only 1. is usefully false. You can in fact easily know whether you’ve already been called during a tx. Sadly, shimming EIP-1153 is quite expensive, and doing it with view functions is super gas-expensive.
Here is the trick: since EIP-2929, the EVM has maintained a tx-scoped “already accessed” flag for each account and storage slot. The assumption was that nodes will keep already accessed data in a cache (and won’t serialize it until the end of the tx), so it is OK to charge less for subsequent accesses to that data. The assumption was probably not that contracts would use that cost difference to store information in those flags? Yet here we are.
Did you call me earlier?
A quirk of the EVM is that you can’t tell what transaction you’re in. There is no TXNUMBER
opcode. All you get is the block number. If you want to prevent a specific sequence of operations within a single tx (but are ok with allowing it at the block level), you’re out of luck. Let’s see how we can use the storage cache to detect within-tx repeat callers.
Detecting a previous call
It is not difficult to check whether you have been called before in the current tx: just warm a storage slot the first time you are called. We will write a function alreadyCalled()
that behaves like this:
// Both tx are in the same block
// TX 1
alreadyCalledInTx(); // false
alreadyCalledInTx(); // true
alreadyCalledInTx(); // true
// TX 2
alreadyCalledInTx(); // false
The core concept is, of course, detecting whether a slot is ‘warm’ (already accessed in the current tx) or not. We’ll cleverly name that general-purpose function isSlotWarm
:
uint constant COLD_SLOAD_COST = 2100;
function isSlotWarm(uint slot) internal view returns (bool warm) {
uint gasBefore = gasleft();
assembly { let data := sload(slot) }
warm = (gasBefore - gasleft()) < COLD_SLOAD_COST;
}
(This is a simplified version. It does not work with optimizations turned on.)
That’s all there is to it: check how much we paid to read a storage slot. alreadyCalledInTx
is just a call to isSlotWarm
with a specific slot argument:
uint constant ALREADY_CALLED_SLOT = ...;
function alreadyCalled() external view returns (bool) {
return isSlotWarm(ALREADY_CALLED_SLOT);
}
It’s worth going step by step:
- The first time you call
alreadyCalled
in a transaction, the cold slotALREADY_CALLED_SLOT
of the current contract is read. This will cost 2100 gas, soisSlotWarm
will returnfalse
. - Each new
alreadyCalled()
within that tx will hit a warmALREADY_CALLED_SLOT
and cost only 100 gas. SoisSlotWarm
will returntrue
. - At the next tx, the slot will be cold again, and
isSlotWarm
will returnfalse
, once again.
Counting previous calls
What we have here is a boolean that survives across calls without using SSTORE
(isn’t it nice how every function so far is view
). Can we go further? Yes. We can count how many times a function was called:
uint constant COUNT_CALLS_SLOT = ...;
function countCalls() external view returns (uint n) {
while (isSlotWarm(COUNT_CALLS_SLOT + n++)) {}
}
The countCalls
function returns the number of times it was called in the current transaction! It does that by checking the warmth of successive slots, starting from COUNT_CALLS_SLOT
. Every new call will warm a new slot, so every time you make a new call, you will visit one additional slot.
So, we can count calls using cache states. And cache states last exactly one tx. Can we maybe store arbitrary data for exactly one tx, and implement transient storage today, without having to wait for EIP-1153?
Shimming EIP-1553
We can use the same temperature checking method to reimplement EIP-1153. We’ll write a pair of functions store1153
and load1153
that behave like this:
// Both tx are in the same block
// TX 1
store1153(slot,36);
load1153(slot); // 36
store1153(slot,2);
load1153(slot); // 2
// TX 2
load1153(slot); // 0
store1153
The implementation of store1153
is actually just a SSTORE:
function store1153(uint slot, uint data) internal {
assembly {
sstore(slot,data)
}
}
load1153
load1153
is more involved. We want something like:
- Check if
slot
is warm. If yes, it was written to during this tx (was it?), so return the value ofslot
. - Otherwise it was not written to, so return 0 (default value).
We need a utility method to fetch a value and its slot temperature:
function loadWithCacheState(uint slot) internal view returns (bool warm, uint data) {
uint gasBefore = gasleft();
assembly { data := sload(slot) }
warm = (gasBefore - gasleft()) < COLD_SLOAD_COST;
}
(This is a simplified version. It does not work with optimizations turned on.)
A very naive person (and by that I mean me when I first tried this) might write load1153
like so:
// DOES NOT WORK
function brokenLoad1153(uint slot) external view returns (uint) {
(bool warm, uint data) = loadWithCacheState(slot);
return warm ? data : 0;
}
Fine at first glance: return data
if the slot is warm; return 0
otherwise. But brokenLoad1153
fails in this scenario:
// Both tx are in the same block
// TX 1
store1153(slot,1);
// TX 2
load1153(slot); // 0
load1153(slot); // 1 (wait what)
It fails because a slot will warm up as we try to read it. So the second call in TX 2 will return the value written in TX 1 (1
) instead of the default value (0
).
We need successive reads to have no influence on each other. So we need to read a slot without warming it. Which is not possible. What is possible is to revert the warming after it occurred. Indeed cache state changes are forgotten by reverts (I don’t think it’s a good thing by the way). Here’s what happens when you revert:
We will implement “stealthy reads” by using revert
. Step one is to fetch a value & its slot temp, then immediately revert. We send information with the revert – namely the value of the slot and whether it was warm, but the slot stays cold.
function loadWithCacheStateAndRevert(uint slot) external view {
(bool warm, uint data) = loadWithCacheState(slot);
// write both values to memory (0..64), then revert
assembly {
mstore(0,warm)
mstore(32,data)
revert(0,64)
}
}
To receive that precious data saved from the revert
, here is stealthLoadWithCacheState
, the sneaky counterpart to loadWithCacheState
. It does not alter the temperature at all!
function stealthLoadWithCacheState(uint slot) internal view returns (bool warm, uint data) {
try this.loadWithCacheStateAndRevert(slot) {} catch(bytes memory _bundle) {
(warm,data) = abi.decode(_bundle,(bool,uint));
}
}
Let’s revisit our load1153
function:
function load1153(uint slot) external view returns (uint) {
(bool warm, uint data) = stealthLoadWithCacheState(slot);
return warm ? data : 0;
}
And check its behavior:
// Both tx are in the same block
// TX 1
store1153(slot,23);
load1153(slot); // 23
// TX 2
load1153(slot); // 0
load1153(slot); // 0 (phew!)
Our pair of functions is quite low-level but behaves as transient storage does: all the stored data only lives within a tx and gets discarded as soon as the current transaction ends. There’s a couple of gotchas:
- This workaround costs more gas than what EIP-1153 proposes.
- Storage space is shared with real storage, so you need to be careful about collisions if you also do low-level storage slot shenanigans for your regular storage needs.
Writing with view functions
tldr: it is possible to write arbitrary data to transient storage using only view functions.
If you go back to part 1, you’ll notice that countCalls()
is view
. Yet it stores information that lives for an entire tx. How? By not writing to storage but to storage cache state.
Let’s exploit that to the max and store arbitrary data using only view functions (none of what follows can be realistically used — we’re just having fun the EVM. We’ll consume 200 million gas units just writing a couple of uint16s 🫠).
Our read and write functions will be loadFromCache(string label) view
and storeInCache(string label, uint16 number) view
. They’ll behave like this:
// Both tx are in the same block
// TX 1
loadFromCache("A"); // 0
storeInCache("A",12);
loadFromCache("A"); // 12
loadFromCache("B"); // 0
// TX 2
loadFromCache("A"); // 0
Nothing weird here, just normal store/load behaviour! Except that those functions are view
, and that you shouldn’t be able to remember stuff across calls using view
functions.
storeInCache
storeInCache
will work like a countCalls()
that counts many calls at once: at a slot determined by label
, it will read number
slots — so the number of warm slots will encode our stored number
. Also, we’ll restrict the stored values to uint16
s, because traversing more than 65k storage slots already costs more than 135M gas.
Naively updating a number won’t work. We need to change which slot we count from every time we do an update. Otherwise check out what happens when the new number is smaller:
To fix this, we’ll also store a version number connected to label
. Every time we’ll call storeInCache(label,x)
, we’ll update the version number and get a new slot to write the number from. Let’s pack that in a dataSlot
function:
function dataSlot(string calldata label) internal view returns (uint) {
uint version = countCalls(uint(keccak256(bytes(label))));
return uint(keccak256(abi.encodePacked(label,version)));
}
dataSlot
uses countCalls
to update a counter that starts at a slot induced by label
, then returns a new, fresh (assuming no collisions) slot. Writing storeInCache
is now super easy:
function storeInCache(string calldata label, uint16 number) external view {
uint slot = dataSlot(label);
while (number > 0) {
isSlotWarm(slot + --number);
}
}
That’s it! Just warm enough slots by reading them. Now let’s move to loadFromCache
.
loadFromCache
For this one, we must use the same trick we used in load1153
and stealthily read version numbers and stored values; otherwise we’d be updating them inadvertently! As with stealthLoadWithCacheState
, we’ll create stealthCountCalls
, which counts the number of successive warm slots but does not add a warm slot after them.
function countCallsAndRevert(uint slot) external view {
uint n = countCalls(slot);
assembly {
mstore(0,n)
revert(0,32)
}
}
function stealthCountCalls(uint slot) internal view returns (uint n) {
try this.countCallsAndRevert(slot) {} catch (bytes memory _n) {
n = abi.decode(_n,(uint)) - 1;
}
}
countCallsAndRevert
loads successive slots until it loads a cold one (this warms the slot), then reverts (this chills the warmed slot) and gives the number of warm slots found (including the newly warmed one) to the caller. In the image below, the number 3 was encoded in the slots, and the revert sends the value 4 to the caller:
Now let’s make a variant of dataSlot
that doesn’t update the version number of a given label:
function stealthDataSlot(string calldata label) internal view returns (uint) {
uint version = stealthCountCalls(uint(keccak256(bytes(label))));
return uint(keccak256(abi.encodePacked(label,version)));
}
The stealthy countCalls
will be called twice by loadFromCache
:
function loadFromCache(string calldata label) external view returns (uint) {
return stealthCountCalls(stealthDataSlot(label));
}
We now have a pair of functions for writing and reading to storage that both have a view
modifier, yet persist across calls. Once again, here is what you can do with it:
// Both tx are in the same block
// loadFromCache & storeInCache are both view
// TX 1
loadFromCache("A"); // 0
storeInCache("A",12);
loadFromCache("A"); // 12
loadFromCache("B"); // 0
// TX 2
loadFromCache("A"); // 0
I hope you had fun. I know I had!
More functions & code that survives optimizations steps are in this repo.