Building your first Uniswap v4 hook

Oleh Rubanik
Coinmonks
18 min readJun 13, 2024

--

Objectives

  • Be able to set up a new Foundry project for building hooks
  • Learn how to mine for proper hook addresses that meet the bitmap requirements
  • Create a simple hook that issues ERC-20 tokens as Points for certain types of trades and liquidity modifications

Let’s start

In today’s session, we’re diving into coding. Our focus will be on creating a straightforward points system.

Imagine you have a cryptocurrency named TOKEN, akin to a memecoin. We aim to integrate our system into ETH <> TOKEN exchange pools. The objective is to encourage users to swap TOKEN for ETH and for liquidity providers (LPs) to contribute to our pool. Additionally, we want to enable users to refer others, earning a commission whenever the referred individual buys TOKEN with ETH or adds liquidity. This incentive mechanism involves issuing a second token called POINTS when specific actions occur. To keep things simple, we’ll establish some basic guidelines:

  1. When a user is referred, we’ll generate a set amount of POINTS (let’s say 500 POINTS) for the referrer.
  2. Whenever a swap occurs, converting ETH to TOKEN, we’ll issue POINTS an equivalent to the ETH swap, along with 10% of that amount to the referrer (if applicable).
  3. When someone adds liquidity, POINTS will be issued based on the amount of ETH added, with 10% of that going to the referrer (if applicable).

The remainder of the lesson delves into the system’s design and the step-by-step coding process. If you’re eager to see the final code, you can find it in my GitHub repository: https://github.com/rubanik00/simple-points-hook-uni-v4

Strategic Blueprint

Before we delve into coding, it’s beneficial to conceptualize the design approach.

Let’s review the available hook functions:

beforeInitialize
afterInitialize
beforeAddLiquidity
beforeRemoveLiquidity
afterAddLiquidity
afterRemoveLiquidity
beforeSwap
afterSwap
beforeDonate
afterDonate

Considering our design objectives outlined in the Introduction section, we aim to issue points in two scenarios:

  1. When a swap transpires, convert ETH to TOKEN.
  2. When liquidity gets added to the pool.

For Scenario (1), we have the option to utilize either beforeSwap or afterSwap.

For Scenario (2), we can employ either beforeAddLiquidity or afterAddLiquidity.

However, we can narrow down our options further by scrutinizing the function signatures of these hooks. Let’s compare beforeSwap and afterSwap:

beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData
)

afterSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
BalanceDelta delta,
bytes calldata hookData
)

Notably, afterSwap furnishes an additional argument — BalanceDelta delta. Is this indispensable? It appears so.

The two types of swaps

During a swap, users define their swap criteria in one of two ways.

1. Exact Input for Output: Users specify a precise amount of input tokens, expecting a calculated amount of output tokens in return. For instance, “I want to trade exactly 1 ETH for an unspecified amount of USDC.”

2. Exact Output for Input: Users indicate a precise amount of output tokens they desire, allowing for a calculated amount of input tokens up to a certain limit. For example, “I want exactly 4000 USDC and can spend up to 1.5 ETH for it.”

The selection between these methods is determined by the amountSpecified parameter of the SwapParams structure. In Uniswap, values are consistently presented from the user’s perspective, offering two options:

1. If users specify a negative value for amountSpecified, indicating “funds leaving the user’s wallet,” it signifies an “exact input for output” swap. Users precisely outline the funds exiting their wallet in exchange for a calculated amount of output tokens.

2. If users specify a positive value for amountSpecified, implying “funds entering the user’s wallet,” denotes an “exact output for input” swap. Users precisely state the funds they want to enter their wallet in exchange for a calculated amount of input tokens.

Thus, since an ETH → TOKEN swap can occur in either manner (exact input for output or exact output for input), we may not always ascertain the exact amount of ETH expended. We can confirm it if users specify ETH as an exact input amount but not if they specify TOKEN as an exact output amount solely from SwapParams.

Choosing the hook functions

Therefore, to reliably figure out how much ETH they are spending - the beforeSwap hook doesn't give us that information. The additional BalanceDelta delta value present in afterSwap though, that has the exact amounts of how much ETH is being spent for how much TOKEN since at that point it has already done those calculations.

So — For Case (1) — minting POINTS in case of swaps, we know we will need to use afterSwap now.

Similarly, for Case (2) — the afterAddLiquidity hook also gives an additional BalanceDelta delta input that is not present in beforeAddLiquidity.

The user might originally send some amount of ETH but when the contract calculates the proper ratio of ETH:TOKEN required to add liquidity in their chosen price range, they may have sent additional ETH than necessary, which will be returned to them.

Therefore, to reliably know exactly how much ETH is actually being added to liquidity - we will again use afterAddLiquidity here because the BalanceDelta delta value can provide the exact information to us.

Creating ‘PointsHook.sol’

Alright, at this point, we can start working on our code.

Setting up Foundry

The first thing we’ll do is set up a new Foundry project.

If you don’t have Foundry installed on your computer already — you can follow the instructions at this link for your operating system — https://book.getfoundry.sh/getting-started/installation

Once Foundry is setup, in your terminal, type the following to initialize a new project:

forge init points-hook

This will create a new folder named points-hook with some boilerplate code inside it.

Now, let’s install the Uniswap v4-periphery contracts as a dependency.

forge install https://github.com/Uniswap/v4-periphery

Next, we’ll set up the remappings so that our shorthand syntax for importing contracts from the dependencies works nicely.

forge remappings > remappings.txt

Finally, we can get rid of the default Counter contract and its associated test and script file that Foundry initially set up for us. To do that, you can either manually delete those files, or just run the following:

rm ./**/Counter*.sol

Great!

One last thing. Since v4 uses transient storage which is only available after Ethereum’s cancun hard fork and on Solidity versions >= 0.8.24 — we must set some config in our foundry.toml config file.

To do that, open up the generated foundry.toml config file, and add the following lines to it:

# foundry.toml

Awesome — now we’re all set up to start building our hook!

Hook Structure

Let’s lay down the framework for our hook. Here’s what we already know:

  1. We’ll utilize the afterSwap and afterAddLiquidity hook functions.
  2. Points will be issued to users through an ERC-20 token.
  3. Referrals need to be tracked.

To kick things off, each hook we develop can inherit from the BaseHook contract, which is an abstract contract found in the v4-periphery dependency. Additionally, our hook contract should also inherit from ERC20 since it will be responsible for minting new POINTS tokens. Fortunately, upon installing v4-periphery, we also gain access to solmate and OpenZeppelin contracts as sub-dependencies. However, for now, we’ll only utilize the solmate ERC20 contract.

Begin by creating a new file named PointsHook.sol within the src/ directory, and insert the following code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {BaseHook} from "v4-periphery/BaseHook.sol";
import {ERC20} from "solmate/tokens/ERC20.sol";

import {CurrencyLibrary, Currency} from "v4-core/types/Currency.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {BalanceDeltaLibrary, BalanceDelta} from "v4-core/types/BalanceDelta.sol";

import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";

import {Hooks} from "v4-core/libraries/Hooks.sol";

contract PointsHook is BaseHook, ERC20 {
// Use CurrencyLibrary and BalanceDeltaLibrary
// to add some helper functions over the Currency and BalanceDelta
// data types
using CurrencyLibrary for Currency;
using BalanceDeltaLibrary for BalanceDelta;

// Keeping track of user => referrer
mapping(address => address) public referredBy;

// Amount of points someone gets for referring someone else
uint256 public constant POINTS_FOR_REFERRAL = 500 * 10 ** 18;

// Initialize BaseHook and ERC20
constructor(
IPoolManager _manager,
string memory _name,
string memory _symbol
) BaseHook(_manager) ERC20(_name, _symbol, 18) {}

// Set up hook permissions to return `true`
// for the two hook functions we are using
function getHookPermissions()
public
pure
override
returns (Hooks.Permissions memory)
{
return
Hooks.Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: false,
beforeRemoveLiquidity: false,
afterAddLiquidity: true,
afterRemoveLiquidity: false,
beforeSwap: false,
afterSwap: true,
beforeDonate: false,
afterDonate: false
});
}

// Stub implementation of `afterSwap`
function afterSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata swapParams,
BalanceDelta delta,
bytes calldata hookData
) external override poolManagerOnly returns (bytes4) {
// We'll add more code here shortly
return this.afterSwap.selector;
}

// Stub implementation for `afterAddLiquidity`
function afterAddLiquidity(
address,
PoolKey calldata key,
IPoolManager.ModifyLiquidityParams calldata,
BalanceDelta delta,
bytes calldata hookData
) external override poolManagerOnly returns (bytes4) {
// We'll add more code here shortly
return this.afterAddLiquidity.selector;
}
}

This should mostly be straightforward. While we’ve imported two libraries that aren’t currently in use, their purpose will become clear soon. Aside from that, we’ve initialized the hook and the ERC-20 contract, established a read-only function to specify the permissions this hook requires, and created placeholder functions for afterSwap and afterAddLiquidity.

It’s worth noting that hook functions always return their function selector at the end. This is crucial, as any return value other than the selector would render the hook call unsuccessful.

Currently, there’s an open pull request that reintroduces functionality for NoOp hooks into v4. This might alter the behavior slightly by eliminating the requirement for function selectors to be returned from hook calls. However, as of now, returning function selectors remains necessary.

Assigning Points

Before we code out the actual hook functions, let’s create a little helper function to mint tokens to the referrer and referee when it is time to assign points. Also, let’s discuss how the users can specify if they were referred by someone in the first place.

Note that both our hook functions — and all hook functions — have a bytes calldata hookData parameter that comes with it. This param can be used to attach arbitrary data for usage by the hook. We'll use this parameter to encode data about who the referrer and the referee are. For convenience and usage on a potential front, let's create first a little helper to do that encoding for us.

function getHookData(
address referrer,
address referree
) public pure returns (bytes memory) {
return abi.encode(referrer, referree);
}

Great — nothing too complex here, just abi.encode'ing two address values.

Now, let’s also create a helper _assignPoints function that we can just call when we figure out how many points to assign that automatically gives the referrer a cut as well.

function _assignPoints(
bytes calldata hookData,
uint256 referreePoints
) internal {

// If no referrer/referree specified, no points will be assigned to anyone
if (hookData.length == 0) return;

// Decode the referrer and referree addresses
(address referrer, address referree) = abi.decode(
hookData,
(address, address)
);

// If referree is the zero address, ignore
if (referree == address(0)) return;

// If this referree is being referred by someone for the first time,
// set the given referrer address as their referrer
// and mint POINTS_FOR_REFERRAL to that referrer address
if (referredBy[referree] == address(0) && referrer != address(0)) {
referredBy[referree] = referrer;
_mint(referrer, POINTS_FOR_REFERRAL);
}

// Mint 10% worth of the referree's points to the referrer
if (referredBy[referree] != address(0)) {
_mint(referrer, referreePoints / 10);
}

// Mint the appropriate number of points to the referree
_mint(referree, referreePoints);
}

afterSwap

Let’s quickly recall our requirements for how and when to assign points for a swap.

  1. Make sure this is an ETH - TOKEN pool
  2. Make sure this swap is to buy TOKEN in exchange for ETH
  3. Mint points equal to 20% of the amount of ETH being swapped in

Let’s replace our stub afterSwap implementation with the following:

function afterSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata swapParams,
BalanceDelta delta,
bytes calldata hookData
) external override poolManagerOnly returns (bytes4) {
// If this is not an ETH-TOKEN pool with this hook attached, ignore
// `isNative` function comes from CurrencyLibrary
if (!key.currency0.isNative()) return this.afterSwap.selector;

// We only mint points if user is buying TOKEN with ETH
if (!swapParams.zeroForOne) return this.afterSwap.selector;

// Mint points equal to 20% of the amount of ETH they spent
// Since its a zeroForOne swap:
// if amountSpecified < 0:
// this is an "exact input for output" swap
// amount of ETH they spent is equal to |amountSpecified|
// if amountSpecified > 0:
// this is an "exact output for input" swap
// amount of ETH they spent is equal to BalanceDelta.amount0()

uint256 ethSpendAmount = swapParams.amountSpecified < 0
? uint256(-swapParams.amountSpecified)
// amount0() function here comes from BalanceDeltaLibrary
: uint256(int256(-delta.amount0()));

// pointsForSwap = 20% of ethSpendAmount
uint256 pointsForSwap = ethSpendAmount / 5;

// Mint the points including any referral points
_assignPoints(hookData, pointsForSwap);

return this.afterSwap.selector;
}

afterAddLiquidity

For adding liquidity, our conditions are mostly similar but simpler.

  1. Make sure it’s an ETH - TOKEN pool
  2. Mint points are equivalent to the amount of ETH being supplied as liquidity

Let’s replace our stub with this:

function afterAddLiquidity(
address,
PoolKey calldata key,
IPoolManager.ModifyLiquidityParams calldata,
BalanceDelta delta,
bytes calldata hookData
) external override poolManagerOnly returns (bytes4) {
// If this is not an ETH-TOKEN pool with this hook attached, ignore
if (!key.currency0.isNative()) return this.afterSwap.selector;

// Mint points equivalent to how much ETH they're adding in liquidity
// amount0() is amount of Token 0 i.e. ETH
// we do `-amount0` because its money leaving the user's wallet so will be a negative value, we flip the sign to make it positive
uint256 pointsForAddingLiquidity = uint256(int256(-delta.amount0()));

// Mint the points including any referral points
_assignPoints(hookData, pointsForAddingLiquidity);

return this.afterAddLiquidity.selector;
}

Testing

If you haven’t used Foundry before, a great reason to use Foundry is that tests and scripts in Foundry are also written in Solidity. Unlike Truffle or Hardhat, this is great because we don’t have to deal with things like constantly converting values to and from BigInt and uint256 and the likes - everything is just native Solidity (with some additional "cheat codes" on top)

For now, though we’ll keep our tests fairly simple just to make sure our code is doing what it’s supposed to be doing.

But — before we start writing tests — let’s take a quick detour and talk about HookMiner

HookMiner

Remember the hook address bitmap?

The PoolManager determines which hook functions to call during the flow of an operation based on a bitmap encoded directly into the hook’s address. Each hook function — like afterSwap - is represented by a true/false value at a specific bit of the hook's address.

Therefore, when we are looking to deploy our code (which we will do on a local testnet node to run tests), we need to “mine” for an address that meets our criteria. This is where HookMiner comes in.

HookMiner is a short Solidity library that basically tries a bunch of different salt values for a CREATE2 contract deployment to try and find a value such that the contract address will fulfill our requirements. We don't have to go into detail about how HookMiner works right now - but you can go through its code and it's not too complex if you understand how CREATE2 works. If you'd like to learn more about CREATE2 deployments - OpenZeppelin has a good doc that explains how it works here - https://docs.openzeppelin.com/cli/2.8/deploying-with-create2

For now, copy-paste the following code and save it as test/utils/HookMiner.sol in your codebase

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.19;

/// @title HookMiner - a library for mining hook addresses
/// @dev This library is intended for `forge test` environments. There may be gotchas when using salts in `forge script` or `forge create`
library HookMiner {
// mask to slice out the top 10 bits of the address
uint160 constant FLAG_MASK = 0x3FF << 150;

// Maximum number of iterations to find a salt, avoid infinite loops
uint256 constant MAX_LOOP = 10_000;

/// @notice Find a salt that produces a hook address with the desired `flags`
/// @param deployer The address that will deploy the hook.
/// In `forge test`, this will be the test contract `address(this)` or the pranking address
/// In `forge script`, this should be `0x4e59b44847b379578588920cA78FbF26c0B4956C` (CREATE2 Deployer Proxy)
/// @param flags The desired flags for the hook address
/// @param seed Use 0 for as a default. An optional starting salt when linearly searching for a salt
/// Useful for finding salts for multiple hooks with the same flags
/// @param creationCode The creation code of a hook contract. Example: `type(Counter).creationCode`
/// @param constructorArgs The encoded constructor arguments of a hook contract. Example: `abi.encode(address(manager))`
/// @return hookAddress salt and corresponding address that was found
/// The salt can be used in `new Hook{salt: salt}(<constructor arguments>)`
function find(
address deployer,
uint160 flags,
uint256 seed,
bytes memory creationCode,
bytes memory constructorArgs
) external pure returns (address, bytes32) {
address hookAddress;
bytes memory creationCodeWithArgs = abi.encodePacked(
creationCode,
constructorArgs
);

uint256 salt = seed;
for (salt; salt < MAX_LOOP; ) {
hookAddress = computeAddress(deployer, salt, creationCodeWithArgs);
if (uint160(hookAddress) & FLAG_MASK == flags) {
return (hookAddress, bytes32(salt));
}

unchecked {
++salt;
}
}

revert("HookMiner: could not find salt");
}

/// @notice Precompute a contract address deployed via CREATE2
/// @param deployer The address that will deploy the hook
/// In `forge test`, this will be the test contract `address(this)` or the pranking address
/// In `forge script`, this should be `0x4e59b44847b379578588920cA78FbF26c0B4956C` (CREATE2 Deployer Proxy)
/// @param salt The salt used to deploy the hook
/// @param creationCode The creation code of a hook contract
function computeAddress(
address deployer,
uint256 salt,
bytes memory creationCode
) public pure returns (address hookAddress) {
return
address(
uint160(
uint256(
keccak256(
abi.encodePacked(
bytes1(0xFF),
deployer,
salt,
keccak256(creationCode)
)
)
)
)
);
}
}

PointsHook.t.sol

Now we can write our tests. Create a file named PointsHook.t.sol under the test/ directory. Note it must end with .t.sol - that's what tells Foundry this is a test file.

If you haven’t done Foundry testing before, I’ll go over a couple key points:

  1. Each test contract must import from the Test.sol contract included in the forge-std library installed by default in every Foundry project
  2. Each test contract must have a setUp() function that does whatever initialization is necessary. In our case, this means deploying the hook and attaching it to a pool, for example.
  3. Every individual test must be a public function in the contract whose name starts with test_

We’ll import mostly the same contracts as in our actual hook code — with a couple neat helpers. Let’s set up the initial structure first:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";

import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol";
import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol";

import {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";

import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";

import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";

import {PointsHook} from "../src/PointsHook.sol";
import {HookMiner} from "./utils/HookMiner.sol";

contract TestPointsHook is Test, Deployers {
using CurrencyLibrary for Currency;

MockERC20 token; // our token to use in the ETH-TOKEN pool

// Native tokens are represented by address(0)
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;

PointsHook hook;

function setUp() public {
// TODO
}
}

Note that we are inheriting from a Deployers contract, provided from @uniswap/v4-core/test/utils/Deployers.sol. This is a nice little helper that contains a bunch of utility functions to ease with testing - for e.g. deployFreshManagerAndRouters() which deploys the PoolManager contract, a SwapRouter, a ModifyPositionRouter, and so on for us without us needing to do it manually. We'll make use of these helpers within our setUp function.

Apart from that, we’re setting some storage values to keep track of certain values we’ll need to use in our individual test functions. This includes our token contract we’ll deploy, wrapped Currency versions of the token pair in the pool we will create, and a reference to our hook contract.

Let’s now start actually writing the setUp function. Basically, before we can test our hook, we need to:

  1. Deploy an instance of the PoolManager
  2. Deploy periphery router contracts for swapping, modifying liquidity, etc
  3. Deploy the TOKEN ERC-20 contract (we'll use MockERC20 here)
  4. Mint a bunch of TOKEN supply to ourselves so we can use it to add liquidity
  5. Mine is a contract address for our hook using HookMiner
  6. Deploy our hook contract
  7. Approve our TOKEN for spending on the periphery router contracts
  8. Create a new pool for ETH and TOKEN with our hook attached
function setUp() public {
// Deploy PoolManager and Router contracts
deployFreshManagerAndRouters();

// Deploy our TOKEN contract
token = new MockERC20("Test Token", "TEST", 18);
tokenCurrency = Currency.wrap(address(token));

// Mint a bunch of TOKEN to ourselves and to address(1)
token.mint(address(this), 1000 ether);
token.mint(address(1), 1000 ether);

// Mine an address that has flags set for
// the hook functions we want
uint160 flags = uint160(
Hooks.AFTER_ADD_LIQUIDITY_FLAG | Hooks.AFTER_SWAP_FLAG
);
(, bytes32 salt) = HookMiner.find(
address(this),
flags,
0,
type(PointsHook).creationCode,
abi.encode(manager, "Points Token", "TEST_POINTS")
);

// Deploy our hook
hook = new PointsHook{salt: salt}(
manager,
"Points Token",
"TEST_POINTS"
);

// Approve our TOKEN for spending on the swap router and modify liquidity router
// These variables are coming from the `Deployers` contract
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);

// Initialize a pool
(key, ) = initPool(
ethCurrency, // Currency 0 = ETH
tokenCurrency, // Currency 1 = TOKEN
hook, // Hook Contract
3000, // Swap Fees
SQRT_RATIO_1_1, // Initial Sqrt(P) value = 1
ZERO_BYTES // No additional `initData`
);
}

With the setup complete, we can start writing tests. For now, we are going to keep things simple and only write two tests:

  1. Add Liquidity + Swap without a referrer — make sure we get points for both adding liquidity and for swapping
  2. Add Liquidity + Swap with a referrer — make sure we and the referrer both get points for both actions

Add Liquidity + Swap (without referrer)

First, let’s create the function for Case (1):

function test_addLiquidityAndSwap() public {
// Set no referrer in the hook data
bytes memory hookData = hook.getHookData(address(0), address(this));

uint256 pointsBalanceOriginal = hook.balanceOf(address(this));

// How we landed on 0.003 ether here is based on computing value of x and y given
// total value of delta L (liquidity delta) = 1 ether
// This is done by computing x and y from the equation shown in Ticks and Q64.96 Numbers lesson
// View the full code for this lesson on GitHub which has additional comments
// showing the exact computation and a Python script to do that calculation for you
modifyLiquidityRouter.modifyLiquidity{value: 0.003 ether}(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: 1 ether
}),
hookData
);
uint256 pointsBalanceAfterAddLiquidity = hook.balanceOf(address(this));

// The exact amount of ETH we're adding (x)
// is roughly 0.299535... ETH
// Our original POINTS balance was 0
// so after adding liquidity we should have roughly 0.299535... POINTS tokens
assertApproxEqAbs(
pointsBalanceAfterAddLiquidity - pointsBalanceOriginal,
2995354955910434,
0.0001 ether // error margin for precision loss
);

// Now we swap
// We will swap 0.001 ether for tokens
// We should get 20% of 0.001 * 10**18 points
// = 2 * 10**14
swapRouter.swap{value: 0.001 ether}(
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: -0.001 ether, // Exact input for output swap
sqrtPriceLimitX96: TickMath.MIN_SQRT_RATIO + 1
}),
PoolSwapTest.TestSettings({
withdrawTokens: true,
settleUsingTransfer: true,
currencyAlreadySent: false
}),
hookData
);
uint256 pointsBalanceAfterSwap = hook.balanceOf(address(this));
assertEq(
pointsBalanceAfterSwap - pointsBalanceAfterAddLiquidity,
2 * 10 ** 14
);
}

As you can see, we first add some liquidity in the pool, ensure we got points equivalent to how much ETH of liquidity we added, then we conduct a swap to purchase TOKEN for 0.001 ETH and ensure we got another 20% of 0.0001 POINTS tokens for the swap.

One thing that may be confusing to see is when we are adding liquidity, we say liquidityDelta = 1 ether, but are only transferring 0.003 ether to the function call. Well, liquidityDelta is not an amount, directly.

If you remember from the Ticks and Q64.96 Numbers lesson — there was an equation we showed that we used to calculate how much y amount of tokens we need if we want to add liquidity over a certain price range given a certain amount of x.

In this case, instead of knowing x beforehand, we are saying we know L beforehand - which is 10**18. We can use the value of L and rearrange the equation to instead calculate both x and y. Refer to the GitHub repo - linked at the top of this lesson - for a deeper explanation of how exactly we do this computation and a python script to do this computation for you as well if you want to recreate it.

Apart from that, the test is fairly basic.

  1. We add liquidity such that we’re adding roughly 0.003 ETH (its actually slightly lower than that)
  2. We check we got roughly 0.003 POINTS
  3. Then we swap 0.001 ETH for TOKEN
  4. We check we got 20% of 0.001 = 0.0002 POINTS for doing that

Add Liquidity + Swap (with referrer)

Now let’s do our second test. It’s mostly similar to the first one, except we supply a referrer address this time, and also ensure the referrer address is getting some tokens from our swaps (their commission).

We treat address(1) as our referrer for this test.

function test_addLiquidityAndSwapWithReferral() public {
bytes memory hookData = hook.getHookData(address(1), address(this));

uint256 pointsBalanceOriginal = hook.balanceOf(address(this));
uint256 referrerPointsBalanceOriginal = hook.balanceOf(address(1));

modifyLiquidityRouter.modifyLiquidity{value: 0.003 ether}(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: 1 ether
}),
hookData
);

uint256 pointsBalanceAfterAddLiquidity = hook.balanceOf(address(this));
uint256 referrerPointsBalanceAfterAddLiquidity = hook.balanceOf(
address(1)
);

assertApproxEqAbs(
pointsBalanceAfterAddLiquidity - pointsBalanceOriginal,
2995354955910434,
0.00001 ether
);
assertApproxEqAbs(
referrerPointsBalanceAfterAddLiquidity -
referrerPointsBalanceOriginal -
hook.POINTS_FOR_REFERRAL(),
299535495591043,
0.000001 ether
);

// Now we swap
// We will swap 0.001 ether for tokens
// We should get 20% of 0.001 * 10**18 points
// = 2 * 10**14
// Referrer should get 10% of that - so 2 * 10**13
swapRouter.swap{value: 0.001 ether}(
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: -0.001 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_RATIO + 1
}),
PoolSwapTest.TestSettings({
withdrawTokens: true,
settleUsingTransfer: true,
currencyAlreadySent: false
}),
hookData
);
uint256 pointsBalanceAfterSwap = hook.balanceOf(address(this));
uint256 referrerPointsBalanceAfterSwap = hook.balanceOf(address(1));

assertEq(
pointsBalanceAfterSwap - pointsBalanceAfterAddLiquidity,
2 * 10 ** 14
);
assertEq(
referrerPointsBalanceAfterSwap -
referrerPointsBalanceAfterAddLiquidity,
2 * 10 ** 13
);
}

At this point, we should be good to go. Run the following in your terminal to check if the tests are passing:

forge test

Further Improvements + Wrap-Up

While this implementation is a good starting point, it’s far from flawless and certainly not ready for heavy production use, even if you’re enthusiastic about the concept. There are several edge cases we haven’t addressed, and our tests are not comprehensive, although given the simplicity of this hook, there may not be many potential issues.

Some improvements we’ve identified but skipped for brevity in this beginner-friendly lesson include:

1. Vulnerability to manipulation: The POINTS token could be easily manipulated by creating a new pool for any random token and attaching this hook to it. Users could then engage in wash-trading to accrue a large number of POINTS. This can be mitigated by restricting the hook to specific token pairs or deploying different ERC-20 contracts for different pools.

2. Liquidity lock-up: Implementing a mechanism within afterRemoveLiquidity to enforce a minimum lock-up period for liquidity could prevent users from exploiting the hook by repeatedly adding and removing liquidity to farm POINTS.

The primary objective of this lesson was not to create a fully-fledged, production-ready hook. This will be a recurring theme throughout the course. While we’ll tackle more complex hooks, they will inevitably have some limitations that render them unsuitable for deployment in their current state, given the time constraints of workshops. Nevertheless, you can refine and enhance these ideas to make them more suitable for production use in your Capstone Projects or personal endeavors. Remember, developing something production-ready is a lengthier process that can’t be condensed into a single workshop or similar setting, so consider these as “Proof of Concepts”.

Nonetheless, we hope you’ve gained valuable insights! You’ve successfully built your first hook and tested it.

Originally, we intended to have you deploy this to Ethereum Sepolia as well. However, as of now, the v4 deployment on Sepolia doesn’t align with our local setup using v4-periphery, making deployment on Sepolia currently infeasible. Once the v4 code freeze occurs, we anticipate a new deployment on testnets, after which we’ll deploy the hooks we create to the testnet. For now, local testing with Foundry and Anvil will suffice.

--

--

Oleh Rubanik
Coinmonks

Senior Solidity Smart Contract Developer passionate about DeFi and RWA. From beginner guides to advanced topics, explore diverse articles with me. Welcome! ;)