adhusson's blog

about

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:

  1. 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.
  2. You can’t have transient storage (storage that persists during a transaction only) without EIP-1153.
  3. 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 slot ALREADY_CALLED_SLOT of the current contract is read. This will cost 2100 gas, so isSlotWarm will return false.
  • Each new alreadyCalled() within that tx will hit a warm ALREADY_CALLED_SLOT and cost only 100 gas. So isSlotWarm will return true.
  • At the next tx, the slot will be cold again, and isSlotWarm will return false, once again.

A cold then warm slot, then cold 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.

How iterating slots can count the number of calls

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 of slot.
  • 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:

Reverting chills warmed slot inside the call

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 🫠).

An improvement by Hubert Ritzdorf reduces the cost of storing numbers to ~600k gas on average, which is still crazy, but 95% less crazy. Instead of storing the numbers in unary he stores them in binary, i.e. he only warms slot slot+i if the ith bit of the number is 1.

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 uint16s, because traversing more than 65k storage slots already costs more than 135M gas.

Heat multiple slots

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:

Heat multiple slots

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:

Count calls and revert just after

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.