The Uniswap V4 Quickstart includes this ominous looking warning about the complexities of swapping directly via the PoolManager contract.
When I see something like this I become very interested — your complexity is my opportunity!
We’re not scared of complexity here, so let’s learn how to swap directly via PoolManager and flex on the haters.
Part II covered the contract structure of PoolManager and highlighted some interesting requirements about how it operates.
A key takeaway from that review was the need to implement the necessary callbacks for direct swapping. This lesson will step through implementing these callbacks in a custom smart contract and then write a series of tests against it to verify that it works as expected.
WARNING: the smart contract developed here is not for production use, should not be deployed to a live blockchain, and is for demonstration purposes only. It is neither optimized nor secure, and should be used only on an isolated local fork using testing accounts.
Development Environment
I will develop the smart contract using Ape Framework and Vyper. At the time of writing, the latest stable release of Ape Framework is version 0.8.25 and Vyper is version 0.4.0. I have both packages installed as standalone tools via uv, but the specific package management tool you use is not important.
unlockCallback
The first point of entry for any state-changing function at the PoolManager is the unlock function. All of the interesting functions in PoolManager.sol have the onlyWhenUnlocked modifier, which is simple:
/// @notice This will revert if the contract is locked
modifier onlyWhenUnlocked() {
if (!Lock.isUnlocked()) ManagerLocked.selector.revertWith();
_;
}
State may only be changed when the lock is open, which we can do by calling unlock
:
/// @inheritdoc IPoolManager
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();
}
The key line is result = IUnlockCallback(msg.sender).unlockCallback(data);
which executes a contract call to the unlockCallback
function at the address of the caller (msg.sender
).
unlock
can be called with arbitrary bytes data that will be passed unmodified to the callback.
After the callback completes, the function does an invariant check: NonzeroDeltaCount.read() != 0
, reverting if the value is not zero.
So our task is to implement unlockCallback
in our contract and ensure that the invariant is met.
NonzeroDeltaCount
But first, what is NonzeroDeltaCount
? It is a library contract providing access to three functions that access a particular slot in transient storage holding the number of non-zero deltas. Recall from Part II that a delta is an accounting of some currency (token address).
The value held in the transient storage slot is a simple counter that can be incremented (+1) or decremented (-1). When a delta goes from zero to non-zero, the counter is incremented. When a delta goes from non-zero to zero, it is decremented.
Payments reconciled using settle
/ settleFor
reset input amounts to zero, and withdrawals reconciled using take
reset output amounts to zero. This also gives a clue about why clear
is provided — transferring small amounts of a token may be expensive, and we have to maintain zero deltas for all currencies, so it’s cheaper in some cases to waive remainders and “balance the books”.
Now, back to unlockCallback
…
The interface for the function is provided in IUnlockCallback.sol:
/// @notice Interface for the callback executed when an address unlocks
/// the pool manager
interface IUnlockCallback {
/// @notice Called by the pool manager on `msg.sender` when the
/// manager is unlocked
/// @param data The data that was passed to the call to unlock
/// @return Any data that you want to be returned from the unlock
/// call
function unlockCallback(bytes calldata data)
external returns (bytes memory);
}
Make a directory, and then create an Ape project in it — I’m using ~/code/uniswap_v4_swap
:
btd@dev:~$ mkdir ~/code/uniswap_v4_swap; cd ~/code/uniswap_v4_swap
btd@dev:~/code/uniswap_v4_swap$ ape init
Project name: Uniswap V4 Swapper
SUCCESS: Uniswap V4 Swapper is written in ape-config.yaml
Create a new Vyper contract in the contracts
folder and fill out the version pragma and define a simple interface matching the Solidity contract:
uniswap_v4_swapper.vy
#pragma version ~=0.4.0
implements: IUnlockCallback
interface IUnlockCallback:
def unlockCallback(data: Bytes[1024]) -> Bytes[1024]: nonpayable
The implements:
statement instructs the Vyper compiler to check that this contract provides all of the listed functions indented below that interface. If we omit a function or define a function that doesn’t match, it will error. This ensures that our contract matches the expected interface exactly.
I’ve set an arbitrary 1024 byte limit on the input and return values. Solidity allows unbounded byte arrays, but Vyper enforces an upper limit. We may reduce this down as our smart contract becomes more sophisticated, but this limit should be sufficient for simple swapping in the short term.
Here we define a minimal unlockCallback
that meets the specification:
@external
@nonpayable
def unlockCallback(data: Bytes[1024]) -> Bytes[1024]:
return b''
And confirm that it compiles using Vyper:
btd@dev:~/code/uniswap_v4_swap$ vyper contracts/uniswap_v4_swapper.vy
0x61008261000f6000396100826000f35f3560e01c6391dd7346811861007a5760243610341761007e576004356004018035610400811161007e5750602081350180826040375050602080610480525f6104605261046081610480015f81528051806020830101601f825f03163682375050601f19601f825160200101169050905081019050610480f35b5f5ffd5b5f80fd8418828000a1657679706572830004000012
And with Ape:
btd@dev:~/code/uniswap_v4_swap$ ape compile
INFO: Compiling using Vyper compiler '0.4.0'.
Input:
contracts/uniswap_v4_swapper.vy
SUCCESS: 'local project' compiled.
To minimize complexity we will perform all of the necessary actions within the callback, and not use the data
value that unlock provides. This allows us to avoid encoding and decoding calldata, which is a bit of a distraction and better covered by other lessons published here.
Contract Call: swap
PoolManager’s swap function has this interface:
/// @notice Swap against the given pool
/// @param key The pool to swap in
/// @param params The parameters for swapping
/// @param hookData The data to pass through to the swap hooks
/// @return swapDelta The balance delta of the address swapping
/// @dev Swapping on low liquidity pools may cause unexpected swap
/// amounts when liquidity available is less than amountSpecified.
/// Additionally note that if interacting with hooks that have the
/// BEFORE_SWAP_RETURNS_DELTA_FLAG or AFTER_SWAP_RETURNS_DELTA_FLAG
/// the hook may alter the swap input/output. Integrators should perform
/// checks on the returned swapDelta.
function swap(
PoolKey memory key,
SwapParams memory params,
bytes calldata hookData)
external
returns (BalanceDelta swapDelta);
Add the interface to our contract to make calling it easier, swapping the type aliases for the basic Vyper types:
interface IPoolManager:
def swap(
key: PoolKey,
params: SwapParams,
hookData: Bytes[1024],
) -> BalanceDelta: nonpayable
This won’t compile though, because Vyper doesn’t know what PoolKey
, SwapParams
, and BalanceDelta
are. So define these too, using their definitions from PoolKey.sol and IPoolManager.sol, and substitute the basic type (int256
) for the BalanceDelta
alias:
Keep reading with a 7-day free trial
Subscribe to Degen Code to keep reading this post and get 7 days of free access to the full post archives.