TL;DR You must manually make sure the storage is transient within the life cycle of an external function, stead of a transaction. Never let the EVM clear non-zero transient storages.
The Solidity's Blog discussed the composibility problem of transient storage very well. Let's see what it says:
So far the EVM largely guaranteed composable behaviour, since multiple calls into a smart contract within a complex transaction are virtually indistinguishable from multiple calls to the contract stretched over several transactions. However, transient storage allows a violation to this principle and incorrect use may lead to complex bugs that only surface when used across several calls.
EIP1153 break such "indistinguishable". These two cases are different:
- multiple calls into a smart contract within a complex transaction: the latter one can read the data which was left by the former one using TSTORE.
- multiple calls to the contract stretched over several transactions: the latter one can NOT read the data which was left by the former one using TSTORE.
Currently the clearing happens for all contracts at the same time, when the transaction ends
If you are not careful, EVM will clear non-zero transient storage at the end of each transaction. It is this behavior that breaks "indistinguishable".
If instead it was cleared for a contract as soon as no function belonging to it remained active on the call stack (which could mean multiple resets per transaction), the issue would disappear.
When a composable external function ends, it must manually clear all the transient storage it has set during its execution. Thus, it keeps the "indistinguishable" attribute. When the EVM clears transient storage at the end of the transaction, it just performs NOPs, i.e., clearing zero storage slots.
Let's see an example from pancake-v4. The SettlementGuard library uses tload and tstore and the Vault utilizes this library.
The Vault's lock function is a composable external function. Its flow is as below:
- The caller calls
lockwith call backdata. - The Vault set the
LOCKER_SLOTtransient storage to acquire a lock. - The Vault call back to the caller's
lockAcquiredfunction withdata. - The caller then calls Vault's other functions:
accountAppBalanceDelta,take,mint,sync,settle,settleFor,clear, andburn. All these functions haveisLockedmodifier and can only work within the life cycle of thelockfunction. During this process, some transient storage slots are temporarily set and finally cleared. - Before the
lockfunction clears theLOCKER_SLOTslot, the Vault checks if all the transient storage slots are cleared. If not, it reverts.
Before the lock function starts, all the transient slots are zero. After it ends, all the transient slots are zero.
In the accountDelta function, every time a zero CURRENCY_DELTA slot turns non-zero, UNSETTLED_DELTAS_COUNT is increased by one. Every time a non-zero CURRENCY_DELTA slot turns zero, UNSETTLED_DELTAS_COUNT is decreased by one. So, if the UNSETTLED_DELTAS_COUNT slot is zero, then all the CURRENCY_DELTA slots are zeros. This means we only need to check the UNSETTLED_DELTAS_COUNT slot at the end of the lock function.
Now we have an interesting deduction: in SettlementGuard.sol, if we change all the tload(tstore) to sload(sstore), the lock function can also work correctly, with a little more gas.
The conclusion: the safe way to use transient storage is to use tload&tstore only for reducing gas. Never take advantage of its special semantics, i.e., get cleared automatically when the transaction ends. Clear all the non-zero transient storage at the end of a composable external function.