Uniswap V4 NoOp Hooks, the quickest guide

Ivan Volovyk
11 min readJun 18, 2024

--

Today we will cover the latest addition to the Uniswap V4 Core family, the NoOp hooks.

This feature provides ultimate flexibility and allows you to build any Dex, Marketplace, or just any contract that swaps one thing into another on top of the Uniswap.

As I was part of Cohort 1 of Uniswap Hook Incubator, I was fortunate enough to be amongst 100 or so people who got the introduction to this concept the day after it was finally merged.

The code for this article was provided by Haardik during one of the many workshops, so shout out to his great work 👏

🚨❗Going forward I assume that you know the basics of writing normal hooks, if not please consider reading these good explanations: [1], [2]. Before proceeding, be sure to understand the concept of Flash Accounting, hook address formation, and Singleton Pool Manager contract.❗🚨

Also, I suggest reading comments inside the code blocks because a big part of the explanation is provided there.

NoOp

It allows you to remove the last parts of the required Uniswap V3 math from the Uniswap V4 Pool. You could now take full control of the user’s swap. For example, if the user said they want to sell 1 ETH for USDC, you can take 0.5 ETH of that 1 ETH and do your own thing with it, while letting the other 0.5 pass through normally.

That means you can replace Uniswap’s internal core logic for how to handle swaps or position modification with your custom logic — and this opens the door to creating anything from custom pricing curves and fee management solutions to the NFT exchange which will use complex traits rarity formula to calculate the price during swap.

In the most extreme use case, all logic is placed inside the hook contact and the Pool serves as an interface for the standardization of the swap function’s input and output arguments. This helps all custom pools share a common part, so they can be aggregated and included in the general order flow.

In simple terms

With normal hooks, you can block certain operations like donate()by reverting every operation in the beforeDonate()hook.

function beforeDonate(
address,
PoolKey calldata,
uint256,
uint256,
bytes calldata
) external virtual returns (bytes4) {
revert ("Don't want to have this function anymore");
}

You can also prevent users from using built-in liquidity provision flow and write custom logic for it.

// Disable standard adding liquidity through the PM
function beforeAddLiquidity(
address,
PoolKey calldata,
IPoolManager.ModifyLiquidityParams calldata,
bytes calldata
) external pure override returns (bytes4) {
revert AddLiquidityThroughHook();
}

// Custom add liquidity function
function customAddLiquidity(
...
) external {
// Unlocking the contract & doing position manipulation inside the callback
poolManager.unlock(
abi.encodeCall(
this.customAddLiquidityCallback,
(...)
)
);
}

But you will still be forced to use poolManager.modifyLiquidity() with V3 math under the hood.

function unlockModifyPosition(
...
) external selfOnly returns (bytes memory) {
// Here we are still bounded to x*y=k curve and V3 tick math
(BalanceDelta delta, ) = poolManager.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: liquidity,
salt: bytes32(ZERO_BYTES)
}),
ZERO_BYTES
);
}

The same goes for swaps, you can do some operations before and after the swap, but the main logic is unchanged. You still will abide by the curve.

But with NoOp you can say:

“Screw it, I will handle it all by myself, specify the custom proportion for liquidity provision, and do swaps the way I like”.

What we are building

Now is a good time to explain what we are building. We will start with the simplest case possible: CSMM Dex.

A CSMM is a pricing curve that follows the invariant x + y = k instead of the invariant x * y = k. You can find some good readings about the benefits of it here. This is theoretically the ideal curve for a stablecoin or pegged pairs (wstETH/ETH).

So, for the amount0 of USDT our user pays, we want to trade exactly amount1 of USDC, where amount0 = amount1. Using the standard curve we will always get some slippage because the price of an asset changes alongside the trade. We will never get exactly amount0 = amount1.

NoOp how?

Firstly, if we want to NoOp smth we need to specify the flag to notify every contract involved. So here is the list of new flags available.

BEFORE_SWAP_RETURNS_DELTA_FLAG = 1 << 149;
AFTER_SWAP_RETURNS_DELTA_FLAG = 1 << 148;
AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 147;
AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 146;

Currently, we can NoOp only swap(), addLiquidity(), and removeLiquidity() functions.

Also due to the increase in the flags combination possibilities the approach of ‘hook mining’ which some people used to do in the tests to find the salt that will give the address a specific format is not optimal anymore.

So we will use the deployCodeTo() Foundry cheat code to deploy our hook code on the existing address for testing.

address hookAddress = address(
uint160(
Hooks.BEFORE_ADD_LIQUIDITY_FLAG |
Hooks.BEFORE_SWAP_FLAG |
Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG
)
);
deployCodeTo("CSMM.sol", abi.encode(manager), hookAddress);
hook = CSMM(hookAddress);

(key, ) = initPool(
currency0,
currency1,
hook,
3000,
SQRT_PRICE_1_1,
ZERO_BYTES
);

For this tutorial, we will only return deltas for the beforeSwap()hook, and now let’s talk about why.

Who are deltas and why return them?

We will deal with 2 types of deltas today:

  • BalanceDeltas which occurs then you unlock() the PoolManager and need to zero them out before the transaction is finished
  • the return value of the beforeSwapHook()called BeforeSwapDelta

Let’s start with the simplest, the first type.

Balance deltas

As you know were are certain functions in the PoolManager that could modify the pool balance. They all have onlyWhenUnlocked modifier. To use them you need to unlock() the PM, do the stuff you want inside the callback, and settle the balance deltas which occurs before the callback is closed.

In this example, we are doing it inside the addLiquidity() function. This is the custom function that overrides the default liquidity provision and always accepts both assets in equal proportions

Here is the pretty straightforward code:

// Custom add liquidity function
function addLiquidity(PoolKey calldata key, uint256 amountEach) external {
// Unlocking the PM
poolManager.unlock(
abi.encode(
CallbackData(
amountEach,
key.currency0,
key.currency1,
msg.sender
)
)
);
}

function unlockCallback(
bytes calldata data
) external override poolManagerOnly returns (bytes memory) {
CallbackData memory callbackData = abi.decode(data, (CallbackData));

// Settle `amountEach` of each currency from the sender
// i.e. Create a debit of `amountEach` of each currency with the Pool Manager
callbackData.currency0.settle(
poolManager,
callbackData.sender,
callbackData.amountEach,
false // `burn` = `false` i.e. we're actually transferring tokens, not burning ERC-6909 Claim Tokens
);
callbackData.currency1.settle(
poolManager,
callbackData.sender,
callbackData.amountEach,
false
);

// Since we didn't go through the regular "modify liquidity" flow,
// The PM just has a debit of `amountEach` of each currency from us
// We can, in exchange, get back ERC-6909 claim tokens for `amountEach` of each currency
// to create a credit of `amountEach` of each currency to us
// that balances out the debit

// We will store those claim tokens with the hook, so when swaps take place
// liquidity from our CSMM can be used by minting/burning claim tokens the hook owns
callbackData.currency0.take(
poolManager,
address(this),
callbackData.amountEach,
true // true = mint claim tokens for the hook, equivalent to the money we just deposited to the PM
);
callbackData.currency1.take(
poolManager,
address(this),
callbackData.amountEach,
true
);

return "";
}

And also straightforward but scary-looking insides of CurrencySettleTake library:

function settle(Currency currency, IPoolManager manager, address payer, uint256 amount, bool burn) internal {
if (burn) {
manager.burn(payer, currency.toId(), amount);
} else if (currency.isNative()) {
manager.settle{value: amount}(currency);
} 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(currency);
}
}

function take(Currency currency, IPoolManager manager, address recipient, uint256 amount, bool claims) internal {
claims ? manager.mint(recipient, currency.toId(), amount) : manager.take(currency, recipient, amount);
}

We are trying to take the two tokens from a user in eq proportion, give them to the PoolManager and create the ‘debit’, or issue ERC6909Claims on these assets for the hook contact so it can own them.

For simplification, you can imagine what the code above does this:

function unlockCallback(
uint256 amountEach
) external override poolManagerOnly returns (bytes memory) {
manager.sync(currency);

IERC20Minimal(currency0).transferFrom(
msg.sender,
address(manager),
amountEach
);
manager.settle(currency0);

IERC20Minimal(currency).transferFrom(
msg.sender,
address(manager),
amountEach
);
manager.settle(currency1);

manager.mint(address(this), currency0.toId(), amountEach);
manager.mint(address(this), currency1.toId(), amountEach);

return "";
}

So we first make what PM owns tokens to someone by transferring the user’s money to it. After that we make the hook to take money from him and zero out this debit.

BeforeSwapDelta

Before swap deltas is similar to balanceDeltas but the order is different:

  • BalanceDelta is a packed value of (int128 currency0Amount, int128 currency1Amount).
  • BeforeSwapDelta varies such that it is not sorted by token0 and token1. Instead, it is sorted by specifiedCurrency and unspecifiedCurrency.

This specified/unspecified order depends on the way the user wants to swap. For example, in an ETH/USDC pool, there are 4 possible swap cases:

  1. Exact Input for Output ETH/USDC, aka I want to trade 1 ETH for the maximum of USDC possible
amountSpecified = -1 * 1e18
specifiedCurrency = ETH
unspecifiedCurrency = USDC

2. Exact Output for Input ETH/USDC, I want to get 3000 USDC for the minimum ETH possible

amountSpecified = 3000 * 1e6
specifiedCurrency = USDC
unspecifiedCurrency = ETH

3. Exact Input for Output USDC/ETH, I want to sell 3000 USDC for the maximum ETH possible

amountSpecified = -1 * 3000 * 1e6
specifiedCurrency = USDC
unspecifiedCurrency = ETH

4. Exact Output for Input USDC/ETH, I want to get 1 ETH for the minimum USDC possible

amountSpecified = 1 * 1e18
specifiedCurrency = ETH
unspecifiedCurrency = USDC

And we can see some causation, specifiedCurrency is always the currency in which the user is specifying the amount they’re swapping for. And unspecifiedCurrency is another currency.

This is written from the perspective of the user. So “-” means what the user is spending the token and “+” what he is receiving it.

This is just the format in which the swap router communicates with PM and hooks. And if we want to write our custom swap logic we need to return this delta in the beforeSwap hook with the proper amounts.

Let’s rewrite the example above for the stablecoin pair and our CSMM curve.

#for USDC-USDT pool

#1. ExactInput USDC/USDT
specifiedCurrency = USDC
amountSpecified = -1 * x * 1e6 ## user will trade x USDC for the max of USDT possible y

unspecifiedCurrency = USDT
amountUnspecidied = x * 1e6 ## according to CSMM it will be the same amount

#2. ExactOutput USDC/USDT
specifiedCurrency = USDT
amountSpecified = x * 1e6

unspecifiedCurrency = USDC
amountUnspecidied = -1 * x * 1e6

#3. Exact Input USDT/USDC
specifiedCurrency = USDT
amountSpecified = -1 * x * 1e6

unspecifiedCurrency = USDC
amountUnspecidied = x * 1e6

#4. Exact Output USDT/USDC
specifiedCurrency = USDC
amountSpecified = x * 1e6

unspecifiedCurrency = USDT
amountUnspecidied = -1 * x * 1e6

But whose perspective is BeforeSwapDelta from?

To understand it let’s just look in the v4/core code where they are used. But we will not dive too deep, so let’s look into it from the perspective of how to stop the standard Pool.swap() from happening, so we can manipulate the tokens ourselves.

// --- Pool Library ---
// This is a swap function we want not to call
function swap(...)
internal
returns (...)
{
// if amountSpecified == 0 we don't proceed with the standard swap
if (params.amountSpecified == 0) return (BalanceDeltaLibrary.ZERO_DELTA, 0, swapFee, state);

(...)
}

// --- Pool manager ---

function _swap(PoolId id, Pool.SwapParams params, ...) internal returns (BalanceDelta) {
(...) = pools[id].swap(params); // <- call of the Pool.swap()
}

function swap(...)
external
override
onlyWhenUnlocked
returns (BalanceDelta swapDelta)
{
(int256 amountToSwap, BeforeSwapDelta beforeSwapDelta) = key.hooks.beforeSwap(key, params, hookData);

swapDelta = _swap(
id,
Pool.SwapParams({
tickSpacing: key.tickSpacing,
zeroForOne: params.zeroForOne,
// <- here we pass amountSpecified = amountToSwap,
// so if amountToSwap == 0 we stop Pool.swap()
amountSpecified: amountToSwap,
sqrtPriceLimitX96: params.sqrtPriceLimitX96
}),
params.zeroForOne ? key.currency0: key.currency1
);
}

// ---- Hooks Library ----

function beforeSwap(...)
internal
returns (int256 amountToSwap, BeforeSwapDelta hookReturn)
{
amountToSwap = params.amountSpecified;

if (self.hasPermission(BEFORE_SWAP_FLAG)) {
if (canReturnDelta) {
int128 hookDeltaSpecified = hookReturn.getSpecifiedDelta();
if (hookDeltaSpecified != 0) {
// here is the zeroing out the amountToSwap
amountToSwap += hookDeltaSpecified;
}
}
}
}

As we can see if hookDeltaSpecified eq — params.amountSpecified the swap will be omitted. So now this code in the beforeSwapHook()makes sense.

BeforeSwapDelta beforeSwapDelta = toBeforeSwapDelta(
int128(-params.amountSpecified), // So `specifiedAmount` = +100
int128(params.amountSpecified) // Unspecified amount (output delta) = -100
);

So we can simply think of it as a perspective of the hook itself.

If the user swaps 1 USDC for 1 USDT:

  • user gives 1 USDC => balance0OfUser is ⬇️
  • hook gets 1 USDC => balance0OfHook is ️️⬆️

So, the swap logic will be omitted, and all the token manipulation will happen inside beforeSwapHook() as we wanted.

Now is the time to finally finish the swap.

Assets manipulations

The code looks similar to the addLiquidity function. HereamountInOutPositive is the abs(params.ampuntSpecified).

And don’t forget that the swap-related asset provided by the user is now already in the PoolManager.

if (params.zeroForOne) {
// If a user is selling Token 0 and buying Token 1

// Claim the token0 provided to the PM by the user on behalf of the hook
key.currency0.take(
poolManager,
address(this),
amountInOutPositive,
true
);

// Transfer token1 to the user by burning the shares of the token owned by the hook
key.currency1.settle(
poolManager,
address(this),
amountInOutPositive,
true
);
} else {
// If the user is selling Token 1 and buying Token 0

// Transfer token0 to the user by burning the shares of the token owned by the hook
key.currency0.settle(
poolManager,
address(this),
amountInOutPositive,
true
);

// Claim the token1 provided to the PM by the user on behalf of the hook
key.currency1.take(
poolManager,
address(this),
amountInOutPositive,
true
);
}

Or we can again rewrite it this way:

if (params.zeroForOne) {
// If the user is selling Token 0 and buying Token 1

// Claim the token0 provided to the PM by the user on behalf of the hook
manager.mint(address(this), currency0.toId(), amountInOutPositive)

// Transfer token1 to the user by burning the shares of the token owned by the hook
manager.burn(address(this), currency1.toId(), amountInOutPositive);
} else {
// Transfer token0 to the user by burning the shares of the token owned by the hook
manager.burn(address(this), currency0.toId(), amountInOutPositive);

// Claim the token1 provided to the PM by the user on behalf of the hook
manager.mint(address(this), currency1.toId(), amountInOutPositive)
}

Here we are just claiming the token transferred by the user to the PM and burning hook assets to send the user the same amount of another token.

That’s all, we are done.

To sum it up

In the end, all we need to write a custom CSMM Dex is:

  • to use the PoolManager accounting system based on ERC-6909 to store the liquidity provided by LPs and also to store the assets provided by users during swaps
  • by specifying BeforeSwapDelta's first value we are turning off the core swap functionality completely
  • and just handling the swap by claiming the debit (surplus) of tokens which occurs then the user transfers the tokens to the PM before the swap
  • and transferring the corresponding amount of another token that is already owned by the hook (burning ERC-6909)

Some finishing thought

This simple example only breaks the ice on the topic, because, as attentive readers might have noticed, many questions remain undisclosed:

  • afterSwapHook was not covered at all
  • We need specifiedDelta = amountSpecified to turn off the swap. What if I need to have a standard swap still occur according to UniswapV4 math but only on part of the token provided?
  • What about unspecifiedDelta? It looks not used
  • Isn’t It easier to write CSMM Dex without Uniswap? it will take less code and be more straightforward?
  • Why we are trying to use Uniswap here if we are using none of its core logic at all?

In the next article, we will explore more complex hooks and you will find the answers.

--

--

Ivan Volovyk
Ivan Volovyk

Written by Ivan Volovyk

Co-founder of Lumis - Structured LP products on top of the Uniswap V4 hook-based options