UniswapV2 offered a very simple method for doing swaps directly at the pool contract. The process was simple:
Send tokens
Call swap
Receive tokens
UniswapV3, to absolutely no one’s surprise, complicates the process by introducing callbacks for minting, swapping, and flash borrowing.
We are not strangers to the callback here at Degen Code. We first encountered them studying UniswapV2 flash borrowing, and then refined it by learning to pass arbitrary calldata through that same callback.
The bad news is that you absolutely cannot do a swap at the pool without including the callback in your smart contract.
There is a nice feature of the V3 callback that I have not seen discussed anywhere else:
With UniswapV2, calling swap at the pool will always execute at the amounts you specify. If you could have gotten a better swap, it will still give you the price you requested.
The V3 callback allows us to always get the best price for a given swap, since the required amount is calculated after the swap calculation is done. This is similar to the behavior of the router, where you submit a minimum output and often receive more.
With V3, you no longer have to pre-send the input amount. Rather, the swap input is paid afterwards.
Happily, the swap callback is fairly simple to implement. There is no guidance on how to properly protect the callback from nefarious use, but we can review the Router’s own callback implementation to see how they protect it.
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes calldata _data
) external override {
require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported
SwapCallbackData memory data = abi.decode(
_data,
(SwapCallbackData)
);
(address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);
(bool isExactInput, uint256 amountToPay) =
amount0Delta > 0
? (tokenIn < tokenOut, uint256(amount0Delta))
: (tokenOut < tokenIn, uint256(amount1Delta));
if (isExactInput) {
pay(tokenIn, data.payer, msg.sender, amountToPay);
} else {
// either initiate the next swap or pay
if (data.path.hasMultiplePools()) {
data.path = data.path.skipToken();
exactOutputInternal(amountToPay, msg.sender, 0, data);
} else {
amountInCached = amountToPay;
tokenIn = tokenOut; // swap in/out because exact output swaps are reversed
pay(tokenIn, data.payer, msg.sender, amountToPay);
}
}
}
If this is good enough for them, it’s good enough for us!
Callback Verification
This function does a few important checks, but we mainly care about the line:
CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);
This line defers the validation of the LP address (which should equal msg.sender
) to the CallbackValidation library. The function verifyCallback
executes this code:
pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
require(msg.sender == address(pool));
Where PoolAddress is another library.
The computeAddress
function executes this somewhat complex check:
function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) {
require(key.token0 < key.token1);
pool = address(
uint256(
keccak256(
abi.encodePacked(
hex'ff',
factory,
keccak256(abi.encode
(
key.token0, key.token1, key.fee
)
),
POOL_INIT_CODE_HASH
)
)
)
);
}
This is pretty big and scary, but the purpose of the function is to generate a 20-byte pool address from a starting hash (POOL_INIT_CODE_HASH
) and some inputs (factory
, token0
, token1
, and fee
).
It uses various Solidity built-ins including abi.encodePacked
, keccak256
, and type casting functions (uint256
and address
).
We can implement this same function in Vyper:
@internal
@pure
def verifyCallback(
tokenA: address,
tokenB: address,
fee: uint24
) -> address:
token0: address = tokenA
token1: address = tokenB
if convert(tokenA,uint160) > convert(tokenB,uint160):
token0 = tokenB
token1 = tokenA
return convert(
slice(
convert(
convert(
keccak256(
concat(
b'\xFF',
convert(V3_FACTORY,bytes20),
keccak256(
_abi_encode(
token0,
token1,
fee
)
),
0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54,
)
),
uint256
),
bytes32
),
12,
20,
),
address
)
First, the function sorts the token addresses. token0
is always stored as the lower value. Vyper does not allow you to compare byte strings directly (Solidity does), so we compare the conversions to a uint256
value instead, then set the markers for each token.
Vyper does not have an abi.encodePacked
function, but it’s simple to build that bytestring ourselves using concat
.
Then we do a series of slices and conversions to transform our value into a 20 byte address, which is returned.
If this function is too exotic for you, you can also call getPool
at the factory, but cross-contract calls are usually more expensive than doing the calculation on-contract. Up to you!
Vyper Implementation
With this built, let’s develop the callback function and implement a series of key checks. To verify that msg.sender
is a legitimate pool, we ask it for three values via these functions:
token0
token1
fee
Then we ask the official UniswapV3 factory “what address do you have stored for token0
, token1
, and fee
?”
If that answer matches msg.sender
, we have a legitimate pool and can proceed with the rest of our swap callback.
Let’s build that now: