Skip to main content

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.