Flash Accounting
In the previous section, we frequently mentioned the variable delta
and the concept of “Flash Accounting.” But what do these terms actually mean?
In Uniswap V3 and earlier versions, swapping across pools—for example, exchanging ETH for DAI—typically required intermediary swaps. First, ETH would be swapped for USDC, and then USDC would be swapped for DAI. At each stage, tokens needed to be transferred between accounts (via transfer
calls). Although a router contract could handle these interactions on behalf of the user, multiple transfer calls were still required, either initiated by the router contract or directly by the user.
With the introduction of EIP-1153 during the Dencun upgrade, a new approach called “Flash Accounting” was introduced. This method replaces the need to write data into persistent storage during every swap step. Instead, it leverages transient storage to temporarily store intermediate calculations, deferring all updates until the final step. This mechanism significantly reduces gas costs associated with storage operations and token transfers. The term “Flash” in Flash Accounting reflects the efficiency and temporary nature of this accounting system, as it performs calculations and updates in a swift, non-permanent manner before finalizing the transaction.
EIP-1153
The main role of EIP-1153 is to involve transient storage by using two opcodes, TLOAD
and TSTORE
, where “T” stands for “transient:”
Transient storage is a new writable data location that is persistent for the duration of a transaction. Because the blockchain doesn’t need to store transient data after the transaction (and thus nodes don’t have to use disk), it is significantly less expensive than traditional storage.
The core purpose of EIP-1153 is to optimize the storage of intermediate states during a transaction’s lifecycle by reducing the frequency of reads and writes to on-chain storage. This enhancement significantly lowers gas costs, particularly in scenarios where transient data is repeatedly accessed or modified during a transaction. For example, while traditional storage operations require 2100 gas for a cold read and 2900 gas for a dirty write, the new opcodes TLOAD
and TSTORE
consume only 100 gas each. This difference highlights the efficiency of transient storage in minimizing gas consumption.
The basic use case of EIP-1153 is to replace the reentrancy guard based on storage. You can read Demystifying EIP-1153: Transient Storage to learn more about this.
Data stored using transient opcodes is valid only within a single transaction and is cleared afterward. Some may think it’s acceptable to “lock” without “unlocking” since all transient storage data is cleared once the transaction is completed. However, this is not a good practice. While it may save 100 gas, it compromises the contract’s composability. For more information, refer to Transient Storage Opcodes in Solidity 0.8.24.
In Uniswap V4, all on-chain data modifications must go through the unlock function first. The unlock function serves as the reentrancy guard in Uniswap V4.
// src/PoolManager.sol
function unlock(bytes calldata data) external override returns (bytes memory result) {
if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
Lock.unlock();
// the caller does everything in this callback, including paying what they owe via calls to settle
result = IUnlockCallback(msg.sender).unlockCallback(data);
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock();
}
By examining the Lock
design, we can observe that it utilizes EIP-1153 to implement the reentrancy guard. The Lock library uses transient storage to manage the lock state during the transaction. Specifically, the IS_UNLOCKED_SLOT
constant defines a transient storage slot where the lock state is stored. The unlock
and lock
functions set the transient storage state to true or false respectively using the tstore
opcode, while the isUnlocked
function retrieves the current lock state using the tload
opcode.
// src/libraries/Lock.sol
library Lock {
// The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
bytes32 internal constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
function unlock() internal {
assembly ("memory-safe") {
// unlock
tstore(IS_UNLOCKED_SLOT, true)
}
}
function lock() internal {
assembly ("memory-safe") {
tstore(IS_UNLOCKED_SLOT, false)
}
}
function isUnlocked() internal view returns (bool unlocked) {
assembly ("memory-safe") {
unlocked := tload(IS_UNLOCKED_SLOT)
}
}
}
Flash Accounting
Pool Manager
In the previous section, we observed that whenever liquidity is modified, or a swap occurs, the function _accountPoolBalanceDelta is invoked. This function is, in fact, the core of Flash Accounting. By utilizing this function, delta information can be stored temporarily in transient storage. During the transaction’s execution—such as when swapping tokens across multiple pools—this temporary delta information can be read and modified. Finally, at the conclusion of the transaction, the take
and settle
methods are used to reconcile the token balances.
// src/PoolManager.sol
function modifyLiquidity(
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
bytes calldata hookData
) external onlyWhenUnlocked noDelegateCall returns (BalanceDelta callerDelta, BalanceDelta feesAccrued) {
...
callerDelta = principalDelta + feesAccrued;
...
_accountPoolBalanceDelta(key, callerDelta, msg.sender);
}
function swap(PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData)
external
onlyWhenUnlocked
noDelegateCall
returns (BalanceDelta swapDelta)
{
...
_accountPoolBalanceDelta(key, swapDelta, msg.sender);
}
Now, let’s take a closer look at _accountPoolBalanceDelta
to understand its design. From the code, we can see that the primary role of _accountPoolBalanceDelta
is to update delta information related to the target user. Specifically, it determines the token amounts that the target will send or receive.
// src/PoolManager.sol
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
_accountDelta(key.currency0, delta.amount0(), target);
_accountDelta(key.currency1, delta.amount1(), target);
}
function _accountDelta(Currency currency, int128 delta, address target) internal {
if (delta == 0) return;
(int256 previous, int256 next) = currency.applyDelta(target, delta);
if (next == 0) {
NonzeroDeltaCount.decrement();
} else if (previous == 0) {
NonzeroDeltaCount.increment();
}
}
In the _accountDelta
function, applyDelta
is used to update the delta information. As shown in the later code snippet, the currency address and target address are combined to compute a keccak256 hash. This hash value is used to uniquely identify the target and currency pair, and serves as a key to store and update the delta value in transient storage. This design ensures that there is a clear association between the currency and the target.
// src/libraries/CurrencyDelta.sol
function applyDelta(Currency currency, address target, int128 delta)
internal
returns (int256 previous, int256 next)
{
bytes32 hashSlot = _computeSlot(target, currency);
assembly ("memory-safe") {
previous := tload(hashSlot)
}
next = previous + delta;
assembly ("memory-safe") {
tstore(hashSlot, next)
}
}
function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
assembly ("memory-safe") {
mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
hashSlot := keccak256(0, 64)
}
}
Another snippet worth noting is increment
and decrement
. Why do we need the if/else condition? The reason is that in cross-pool swap scenarios, multiple deltas are temporarily constructed (each pool generates its own delta). We need to ensure that each delta value is ultimately cleared to 0, which is essential for maintaining the contract’s composability.
Each time next == 0
, it indicates that the delta value has been cleared, so we decrement the count by 1. Similarly, when previous == 0
, it means a new delta is being set, so we increment the count by 1. At the end of the transaction, we check the temporary count data to ensure that the count is 0. This guarantees that all deltas have been reconciled.
// src/libraries/NonzeroDeltaCount.sol
function increment() internal {
assembly ("memory-safe") {
let count := tload(NONZERO_DELTA_COUNT_SLOT)
count := add(count, 1)
tstore(NONZERO_DELTA_COUNT_SLOT, count)
}
}
function decrement() internal {
assembly ("memory-safe") {
let count := tload(NONZERO_DELTA_COUNT_SLOT)
count := sub(count, 1)
tstore(NONZERO_DELTA_COUNT_SLOT, count)
}
}
unlockCallback
In the previous section, we analyzed how to record delta. Now, let’s examine how delta is consumed in unlockCallback
. As shown in the code snippets below, whether modifying liquidity or performing a swap, both operations first use _fetchBalances
to retrieve delta values.
// src/test/PoolModifyLiquidityTest.sol
function unlockCallback(bytes calldata rawData) external returns (bytes memory) {
...
(,, int256 delta0) = _fetchBalances(data.key.currency0, data.sender, address(this));
(,, int256 delta1) = _fetchBalances(data.key.currency1, data.sender, address(this));
...
}
// src/test/PoolSwapTest.sol
function unlockCallback(bytes calldata rawData) external returns (bytes memory) {
...
(,, int256 deltaBefore0) = _fetchBalances(data.key.currency0, data.sender, address(this));
(,, int256 deltaBefore1) = _fetchBalances(data.key.currency1, data.sender, address(this));
BalanceDelta delta = manager.swap(data.key, data.params, data.hookData);
(,, int256 deltaAfter0) = _fetchBalances(data.key.currency0, data.sender, address(this));
(,, int256 deltaAfter1) = _fetchBalances(data.key.currency1, data.sender, address(this));
...
}
Now, let’s take a look at the _fetchBalances
function to understand how it works. The role of the _fetchBalances
function is to retrieve the user’s token balances that were temporarily recorded by _accountPoolBalanceDelta
earlier.
// src/test/PoolTestBase.sol
function _fetchBalances(Currency currency, address user, address deltaHolder)
internal
view
returns (uint256 userBalance, uint256 poolBalance, int256 delta)
{
userBalance = currency.balanceOf(user);
poolBalance = currency.balanceOf(address(manager));
delta = manager.currencyDelta(deltaHolder, currency);
}
The currencyDelta function is responsible for combining the currency address and the target address to compute their hash. This hash value is then used as a key to retrieve the corresponding delta value from transient storage. For internal functions, the value can be directly returned using the tload
instruction. However, for external functions like exttload
, the value must first be written to memory using mstore
before being returned via the return opcode, as required by the ABI encoding process. This pattern can be observed in the implementation of both internal transient storage reads and external-facing functions that expose transient storage data.
// src/libraries/TransientStateLibrary.sol
function currencyDelta(IPoolManager manager, address target, Currency currency) internal view returns (int256) {
bytes32 key;
assembly ("memory-safe") {
mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
key := keccak256(0, 64)
}
return int256(uint256(manager.exttload(key)));
}
// src/Exttload.sol
abstract contract Exttload is IExttload {
/// @inheritdoc IExttload
function exttload(bytes32 slot) external view returns (bytes32) {
assembly ("memory-safe") {
mstore(0, tload(slot))
return(0, 0x20)
}
}
// src/libraries/CurrencyReserves.sol
function getSyncedCurrency() internal view returns (Currency currency) {
assembly ("memory-safe") {
currency := tload(CURRENCY_SLOT)
}
}
settle and take
With delta information, the contract’s final step is to either settle
or take
tokens for the users. Let’s explore how this process works.
// src/test/PoolModifyLiquidityTest.sol
if (delta0 < 0) data.key.currency0.settle(manager, data.sender, uint256(-delta0), data.settleUsingBurn);
if (delta0 > 0) data.key.currency0.take(manager, data.sender, uint256(delta0), data.takeClaims);
// src/test/PoolSwapTest.sol
if (deltaAfter0 < 0) {
data.key.currency0.settle(manager, data.sender, uint256(-deltaAfter0), data.testSettings.settleUsingBurn);
}
if (deltaAfter0 > 0) {
data.key.currency0.take(manager, data.sender, uint256(deltaAfter0), data.testSettings.takeClaims);
}
For ERC-6909 tokens, we use the burn
function to handle them. We will analyze ERC-6909 in detail in the next section.
Another case involves transferring native ETH. For ETH, we simply call manager.settle{value: amount}()
to transfer ETH to the Pool Manager contract. From the isAddressZero
function, we know that if the currency address is 0, it means the token is ETH.
The final case is the transfer of ERC-20 tokens. In this scenario, we use the standard ERC-20 transfer method to move the tokens.
As shown in the settle function, the key point of managing delta lies in the manager.settle()
function.
// test/utils/CurrencySettler.sol
function settle(Currency currency, IPoolManager manager, address payer, uint256 amount, bool burn) internal {
// for native currencies or burns, calling sync is not required
// short circuit for ERC-6909 burns to support ERC-6909-wrapped native tokens
if (burn) {
manager.burn(payer, currency.toId(), amount);
} else if (currency.isAddressZero()) {
manager.settle{value: amount}();
} else {
manager.sync(currency);
if (payer != address(this)) {
IERC20Minimal(Currency.unwrap(currency)).transferFrom(payer, address(manager), amount);
} else {
IERC20Minimal(Currency.unwrap(currency)).transfer(address(manager), amount);
}
manager.settle();
}
}
// src/types/Currency.sol
function isAddressZero(Currency currency) internal pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(ADDRESS_ZERO);
}
In the _settle
function, the contract calculates the token amount that needs to be paid and updates the delta information accordingly.
The function first retrieves the current synced currency using CurrencyReserves.getSyncedCurrency
. If the currency is native (e.g., ETH), the msg.value
is directly used as the paid amount. For non-native currencies, the function checks that no msg.value
is provided and calculates the delta based on the difference between the reserves before and after the operation (reservesNow - reservesBefore
). This ensures accurate tracking of token transfers and updates the synced reserves.
Finally, the _accountDelta
function is called to update the delta information for the recipient. This step ensures that all balance adjustments are properly recorded and reconciled in transient storage.
// src/PoolManager.sol
function settle() external payable onlyWhenUnlocked returns (uint256) {
return _settle(msg.sender);
}
function _settle(address recipient) internal returns (uint256 paid) {
Currency currency = CurrencyReserves.getSyncedCurrency();
// if not previously synced, or the syncedCurrency slot has been reset, expects native currency to be settled
if (currency.isAddressZero()) {
paid = msg.value;
} else {
if (msg.value > 0) NonzeroNativeValue.selector.revertWith();
// Reserves are guaranteed to be set because currency and reserves are always set together
uint256 reservesBefore = CurrencyReserves.getSyncedReserves();
uint256 reservesNow = currency.balanceOfSelf();
paid = reservesNow - reservesBefore;
CurrencyReserves.resetCurrency();
}
_accountDelta(currency, paid.toInt128(), recipient);
}
// src/libraries/CurrencyReserves.sol
function getSyncedCurrency() internal view returns (Currency currency) {
assembly ("memory-safe") {
currency := tload(CURRENCY_SLOT)
}
}
At last
We should note that for the Uniswap swap model involving reading and modifying data, EIP-1153 cannot directly save gas. This is because reading the pool’s token state and writing the updated token data are necessary operations that cannot be skipped (involving both cold reads and dirty writes). However, in Uniswap V3 and earlier versions, when swapping tokens across multiple pools, each pool crossing required multiple transfer calls. These operations generated significant gas consumption.
With EIP-1153 and the singleton pool design, there is no need to perform actual token transfers for each pool crossing. Instead, temporary data is stored in transient storage, and the final token state is written to the blockchain only once at the end of the transaction. This means that only a single transfer is executed instead of performing multiple transfers, significantly reducing gas costs.