Building your first Uniswap v4 hook
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:
- When a user is referred, we’ll generate a set amount of
POINTS
(let’s say 500POINTS
) for the referrer. - Whenever a swap occurs, converting
ETH
toTOKEN
, we’ll issuePOINTS
an equivalent to theETH
swap, along with 10% of that amount to the referrer (if applicable). - When someone adds liquidity,
POINTS
will be issued based on the amount ofETH
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:
- When a swap transpires, convert
ETH
toTOKEN
. - 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
solc_version = '0.8.25'
evm_version = "cancun"
optimizer_runs = 800
via_ir = false
ffi = true
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:
- We’ll utilize the afterSwap and afterAddLiquidity hook functions.
- Points will be issued to users through an
ERC-20
token. - 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 intov4
. 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.
- Make sure this is an
ETH - TOKEN
pool - Make sure this swap is to buy
TOKEN
in exchange forETH
- 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.
- Make sure it’s an
ETH - TOKEN
pool - 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:
- Each test contract must import from the
Test.sol
contract included in theforge-std
library installed by default in every Foundry project - 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. - 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:
- Deploy an instance of the PoolManager
- Deploy periphery router contracts for swapping, modifying liquidity, etc
- Deploy the
TOKEN
ERC-20 contract (we'll useMockERC20
here) - Mint a bunch of
TOKEN
supply to ourselves so we can use it to add liquidity - Mine is a contract address for our hook using
HookMiner
- Deploy our hook contract
- Approve our
TOKEN
for spending on the periphery router contracts - Create a new pool for
ETH
andTOKEN
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:
- Add Liquidity + Swap without a referrer — make sure we get points for both adding liquidity and for swapping
- 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.
- We add liquidity such that we’re adding roughly 0.003 ETH (its actually slightly lower than that)
- We check we got roughly 0.003 POINTS
- Then we swap 0.001 ETH for TOKEN
- 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.