Skip to content

Commit 3c274d9

Browse files
authored
add pre-purchase hook and helpers (#1344)
1 parent d637e09 commit 3c274d9

File tree

15 files changed

+1035
-240
lines changed

15 files changed

+1035
-240
lines changed

.changeset/large-eggs-rescue.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@zoralabs/protocol-deployments": patch
3+
"@zoralabs/coins": patch
4+
---
5+
6+
Created a buy supply with v4 hook, for buying initial supply of a creator coin, supporting doing a v3 and v4 swap to buy the creator coin. updated the encoding function for buying initial supply to work with the v3 to v4 swap

packages/coins/addresses/8453.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"BUY_SUPPLY_WITH_SWAP_ROUTER_HOOK": "0xc90e349360C43a0217CEF289f231C66D4748960F",
2+
"BUY_SUPPLY_WITH_SWAP_ROUTER_HOOK": "0xd8CC7bCA1dE52eA788829B16E375e9B96C18D433",
33
"COIN_V3_IMPL": "0x45Bf86430af7CD071Ea23aE52325A78C8d12aD5a",
44
"COIN_V4_IMPL": "0x7Cad62748DDf516CF85bC2C05C14786D84Cf861c",
55
"COIN_VERSION": "2.3.0",

packages/coins/script/DeployPostDeploymentHooks.s.sol

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ contract DeployHooks is CoinsDeployerBase {
1010

1111
vm.startBroadcast();
1212

13-
// address buySupplyWithSwapRouterHook = address(deployBuySupplyWithSwapRouterHook(deployment));
14-
15-
// deployment.buySupplyWithSwapRouterHook = buySupplyWithSwapRouterHook;
13+
deployment.buySupplyWithSwapRouterHook = address(deployBuySupplyWithV4SwapHook(deployment));
1614

1715
vm.stopBroadcast();
1816

packages/coins/src/deployment/CoinsDeployerBase.sol

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {ProxyShim} from "../../test/utils/ProxyShim.sol";
1818
import {CreatorCoin} from "../CreatorCoin.sol";
1919
import {Create2} from "@openzeppelin/contracts/utils/Create2.sol";
2020
import {HookUpgradeGate} from "../hooks/HookUpgradeGate.sol";
21+
import {BuySupplyWithV4SwapHook} from "../hooks/deployment/BuySupplyWithV4SwapHook.sol";
2122

2223
contract CoinsDeployerBase is ProxyDeployerScript {
2324
address internal constant PROTOCOL_REWARDS = 0x7777777F279eba3d3Ad8F4E708545291A6fDBA8B;
@@ -123,14 +124,14 @@ contract CoinsDeployerBase is ProxyDeployerScript {
123124
return new ZoraFactoryImpl({coinV4Impl_: coinV4Impl_, creatorCoinImpl_: creatorCoinImpl_, hook_: hook_, zoraHookRegistry_: zoraHookRegistry_});
124125
}
125126

126-
// function deployBuySupplyWithSwapRouterHook(CoinsDeployment memory deployment) internal returns (BuySupplyWithSwapRouterHook) {
127-
// return
128-
// new BuySupplyWithSwapRouterHook({
129-
// _factory: IZoraFactory(deployment.zoraFactory),
130-
// _swapRouter: getUniswapSwapRouter(),
131-
// _poolManager: getUniswapV4PoolManager()
132-
// });
133-
// }
127+
function deployBuySupplyWithV4SwapHook(CoinsDeployment memory deployment) internal returns (BuySupplyWithV4SwapHook) {
128+
return
129+
new BuySupplyWithV4SwapHook({
130+
_factory: IZoraFactory(deployment.zoraFactory),
131+
_swapRouter: getUniswapSwapRouter(),
132+
_poolManager: getUniswapV4PoolManager()
133+
});
134+
}
134135

135136
function deployUpgradeGate(CoinsDeployment memory deployment) internal returns (CoinsDeployment memory) {
136137
deployment.hookUpgradeGate = address(new HookUpgradeGate(getProxyAdmin()));
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6+
import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
7+
import {IPoolManager, SwapParams} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
8+
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
9+
import {BalanceDelta, BalanceDeltaLibrary} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
10+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
11+
import {BaseCoinDeployHook} from "./BaseCoinDeployHook.sol";
12+
import {IUniswapV3SwapCallback} from "../../interfaces/IUniswapV3SwapCallback.sol";
13+
import {ICoin} from "../../interfaces/ICoin.sol";
14+
import {ISwapRouter} from "../../interfaces/ISwapRouter.sol";
15+
import {IZoraFactory} from "../../interfaces/IZoraFactory.sol";
16+
import {ICoinV3} from "../../interfaces/ICoinV3.sol";
17+
import {CoinConfigurationVersions} from "../../libs/CoinConfigurationVersions.sol";
18+
import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol";
19+
import {Path} from "@zoralabs/shared-contracts/libs/UniswapV3/Path.sol";
20+
21+
/// @title BuySupplyWithV4SwapHook
22+
/// @notice Hook for purchasing initial coin supply with flexible swap routing
23+
/// @dev Capabilities:
24+
/// - ETH → V3 swap → V4 swap → coin (e.g., ETH → ZORA → Creator Coin → Content Coin)
25+
/// - ETH → V3 swap → coin (e.g., ETH → ZORA for ZORA-backed coin)
26+
/// - ETH → V4 swap → coin (direct ETH-paired coins)
27+
/// - ERC20 → V4 swap → coin (e.g., Creator Coins → Content Coin)
28+
/// - Slippage protection with minAmountOut validation
29+
///
30+
/// Limitations:
31+
/// - V3 swaps only support ETH as input currency
32+
/// - ERC20 input currencies require pre-approval
33+
/// - V3 and V4 routes must connect properly (V3 output = V4 input)
34+
contract BuySupplyWithV4SwapHook is BaseCoinDeployHook {
35+
using BalanceDeltaLibrary for BalanceDelta;
36+
using SafeERC20 for IERC20;
37+
using CurrencyLibrary for Currency;
38+
using Path for bytes;
39+
40+
// ============ STATE VARIABLES ============
41+
42+
ISwapRouter public immutable swapRouter;
43+
IPoolManager public immutable poolManager;
44+
45+
// ============ STRUCTS ============
46+
47+
struct InitialSupplyParams {
48+
address buyRecipient; // Who gets the coins
49+
bytes v3Route; // V3 route from ETH to backing currency
50+
PoolKey[] v4Route; // V4 route from backing currency to coin
51+
address inputCurrency; // Currency to use for the V3 swap
52+
uint256 inputAmount; // Amount of input currency to use for the V3 swap
53+
uint256 minAmountOut; // Minimum amount of coins to receive from final swap
54+
}
55+
56+
event BuyInitialSupply(
57+
address indexed coin,
58+
address indexed recipient,
59+
uint256 indexed coinsPurchased,
60+
bytes v3Route,
61+
PoolKey[] v4Route,
62+
address inputCurrency,
63+
uint256 inputAmount,
64+
uint256 v4SwapInput
65+
);
66+
67+
// ============ ERRORS ============
68+
69+
error OnlyPoolManager();
70+
error InsufficientInputCurrency(uint256 inputAmount, uint256 availableAmount);
71+
error V3RouteCannotStartWithInputCurrency();
72+
error V3RouteDoesNotConnectToV4RouteStart();
73+
error InsufficientOutputAmount();
74+
75+
// ============ CONSTRUCTOR ============
76+
77+
constructor(IZoraFactory _factory, address _swapRouter, address _poolManager) BaseCoinDeployHook(_factory) {
78+
swapRouter = ISwapRouter(_swapRouter);
79+
poolManager = IPoolManager(_poolManager);
80+
}
81+
82+
// ============ MAIN HOOK FUNCTION ============
83+
84+
/// @notice Hook that buys supply for a coin using V3->V4 two-step swap routing
85+
/// @dev Returns abi encoded (uint256 amountCurrency, uint256 coinsPurchased)
86+
function _afterCoinDeploy(address, ICoin coin, bytes calldata hookData) internal override returns (bytes memory) {
87+
// STEP 1: Decode parameters
88+
InitialSupplyParams memory params = abi.decode(hookData, (InitialSupplyParams));
89+
90+
PoolKey[] memory v4Route = _buildV4RouteToCoin(coin, params.v4Route);
91+
92+
// STEP 2: Validate routes
93+
_validateRoutes(params, v4Route);
94+
95+
_validateAndTransferInputCurrency(params);
96+
97+
// STEP 3: Execute V3 swap (inputCurrency -> backing currency)
98+
(uint256 currencyAmount, address currencyReceived) = _executeV3Swap(params);
99+
100+
// STEP 4: Execute V4 swaps if needed, then buy coin
101+
uint256 coinAmount = _executeV4Swap(v4Route, currencyAmount, currencyReceived, params.buyRecipient);
102+
103+
// Validate minimum amount of coins received from final swap
104+
require(coinAmount >= params.minAmountOut, InsufficientOutputAmount());
105+
106+
emit BuyInitialSupply({
107+
recipient: params.buyRecipient,
108+
coin: address(coin),
109+
v3Route: params.v3Route,
110+
v4Route: v4Route,
111+
inputCurrency: params.inputCurrency,
112+
inputAmount: params.inputAmount,
113+
v4SwapInput: currencyAmount,
114+
coinsPurchased: coinAmount
115+
});
116+
117+
// STEP 5: Return results
118+
return abi.encode(currencyAmount, coinAmount);
119+
}
120+
121+
// ============ VALIDATION ============
122+
123+
function _validateRoutes(InitialSupplyParams memory params, PoolKey[] memory v4Route) internal pure {
124+
// Determine what currency should be the input to the V4 route
125+
address v4InputCurrency;
126+
if (params.v3Route.length == 0) {
127+
// No V3 swap - input currency should directly match V4 route start
128+
v4InputCurrency = params.inputCurrency;
129+
} else {
130+
// V3 swap exists - V3 output should match V4 route start
131+
v4InputCurrency = _getV3RouteOutputCurrency(params.v3Route);
132+
}
133+
134+
PoolKey memory firstPool = v4Route[0];
135+
136+
require(
137+
v4InputCurrency == Currency.unwrap(firstPool.currency0) || v4InputCurrency == Currency.unwrap(firstPool.currency1),
138+
V3RouteDoesNotConnectToV4RouteStart()
139+
);
140+
}
141+
142+
function _validateAndTransferInputCurrency(InitialSupplyParams memory params) internal {
143+
if (params.inputCurrency == address(0)) {
144+
uint256 providedAmount = msg.value;
145+
146+
require(providedAmount == params.inputAmount, InsufficientInputCurrency(params.inputAmount, providedAmount));
147+
} else {
148+
uint256 providedAmount = IERC20(params.inputCurrency).allowance(params.buyRecipient, address(this));
149+
150+
// must be enough allowance to transfer
151+
require(providedAmount >= params.inputAmount, InsufficientInputCurrency(params.inputAmount, providedAmount));
152+
153+
// transfer from the buy recipient to this contract
154+
IERC20(params.inputCurrency).safeTransferFrom(params.buyRecipient, address(this), params.inputAmount);
155+
}
156+
}
157+
158+
function _buildV4RouteToCoin(ICoin coin, PoolKey[] memory v4Route) internal view returns (PoolKey[] memory fullRoute) {
159+
fullRoute = new PoolKey[](v4Route.length + 1);
160+
161+
for (uint256 i = 0; i < v4Route.length; i++) {
162+
fullRoute[i] = v4Route[i];
163+
}
164+
165+
fullRoute[v4Route.length] = coin.getPoolKey();
166+
}
167+
168+
// ============ V3 SWAP LOGIC ============
169+
170+
function _executeV3Swap(InitialSupplyParams memory params) internal returns (uint256 amountCurrency, address currencyReceived) {
171+
if (params.v3Route.length == 0) {
172+
// No V3 swap needed - return inputAmount directly
173+
return (params.inputAmount, params.inputCurrency);
174+
}
175+
176+
// for v3 swap section, we dont support currently having an input currency other than eth
177+
if (params.inputCurrency != address(0)) {
178+
revert V3RouteCannotStartWithInputCurrency();
179+
}
180+
181+
// Build swap router call for exactInput
182+
ISwapRouter.ExactInputParams memory swapParams = ISwapRouter.ExactInputParams({
183+
path: params.v3Route,
184+
recipient: address(this),
185+
amountIn: params.inputAmount,
186+
amountOutMinimum: 0 // For testing - in production should have slippage protection
187+
});
188+
189+
amountCurrency = swapRouter.exactInput{value: params.inputAmount}(swapParams);
190+
191+
currencyReceived = _getV3RouteOutputCurrency(params.v3Route);
192+
}
193+
194+
function _executeV4Swap(PoolKey[] memory v4Route, uint256 amountIn, address currencyIn, address buyRecipient) internal returns (uint256 amountCoin) {
195+
Currency startingCurrency = Currency.wrap(currencyIn);
196+
bytes memory data = abi.encode(v4Route, amountIn, startingCurrency, buyRecipient);
197+
bytes memory result = poolManager.unlock(data);
198+
amountCoin = abi.decode(result, (uint256));
199+
}
200+
201+
/// @notice Callback for V4 swaps through route or coin purchase
202+
function unlockCallback(bytes calldata data) external returns (bytes memory) {
203+
require(msg.sender == address(poolManager), OnlyPoolManager());
204+
205+
(PoolKey[] memory v4Route, uint256 amountIn, Currency startingCurrency, address buyRecipient) = abi.decode(
206+
data,
207+
(PoolKey[], uint256, Currency, address)
208+
);
209+
210+
Currency lastReceivedCurrency = startingCurrency;
211+
uint128 lastReceivedAmount = uint128(amountIn);
212+
// Execute swaps through the route
213+
214+
uint128 outputAmount = 0;
215+
for (uint256 i = 0; i < v4Route.length; i++) {
216+
PoolKey memory poolKey = v4Route[i];
217+
218+
// Determine swap direction based on current currency
219+
bool zeroForOne = lastReceivedCurrency == poolKey.currency0;
220+
221+
BalanceDelta delta = poolManager.swap(
222+
poolKey,
223+
SwapParams(zeroForOne, -(int128(lastReceivedAmount)), zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1),
224+
""
225+
);
226+
227+
// Extract output amount from delta
228+
outputAmount = zeroForOne ? uint128(delta.amount1()) : uint128(delta.amount0());
229+
230+
// Update currentAmount for next iteration
231+
lastReceivedAmount = uint128(outputAmount);
232+
233+
// Update current currency for next swap
234+
lastReceivedCurrency = zeroForOne ? poolKey.currency1 : poolKey.currency0;
235+
}
236+
237+
// Settle all currency deltas and get final amount
238+
_settleDeltas(startingCurrency, lastReceivedCurrency, buyRecipient, amountIn, outputAmount);
239+
240+
return abi.encode(lastReceivedAmount);
241+
}
242+
243+
/// @notice Helper to decode V4 route data (external for try/catch)
244+
function decodeV4RouteData(bytes calldata data) external pure returns (PoolKey[] memory v4Route, uint256 startAmount) {
245+
return abi.decode(data, (PoolKey[], uint256));
246+
}
247+
248+
function encodeBuySupplyWithV4SwapHookData(InitialSupplyParams memory params) external pure returns (bytes memory) {
249+
return abi.encode(params);
250+
}
251+
252+
function _settleDeltas(Currency inputCurrency, Currency outputCurrency, address to, uint256 inputAmount, uint128 outputAmount) private {
253+
// pay the input amount
254+
if (inputCurrency.isAddressZero()) {
255+
// For ETH, settle with msg.value
256+
poolManager.settle{value: inputAmount}();
257+
} else {
258+
// For ERC20, sync and transfer
259+
poolManager.sync(inputCurrency);
260+
inputCurrency.transfer(address(poolManager), inputAmount);
261+
poolManager.settle();
262+
}
263+
264+
// transfer the output amount to the recipient
265+
poolManager.take(outputCurrency, to, outputAmount);
266+
}
267+
268+
// ============ UTILITIES ============
269+
270+
function _getCoinBackingCurrency(ICoin coin) internal view returns (Currency) {
271+
PoolKey memory poolKey = coin.getPoolKey();
272+
273+
if (Currency.unwrap(poolKey.currency0) == address(coin)) {
274+
return poolKey.currency1;
275+
}
276+
return poolKey.currency0;
277+
}
278+
279+
function _getV3RouteOutputCurrency(bytes memory path) internal pure returns (address tokenOut) {
280+
if (path.length == 0) {
281+
// if no path, then output currency is eth
282+
return address(0);
283+
}
284+
285+
// For a path with multiple pools, we need to traverse to the end
286+
// Path format: tokenA + fee + tokenB + fee + tokenC...
287+
// We want the final token (tokenC in this example)
288+
289+
// Follow Uniswap's pattern: traverse the path to find the final token
290+
bytes memory currentPath = path;
291+
292+
// Keep skipping tokens until we reach the final pool
293+
while (currentPath.hasMultiplePools()) {
294+
currentPath = currentPath.skipToken();
295+
}
296+
297+
// The final segment contains the last pool, decode to get the output token
298+
(, tokenOut, ) = currentPath.decodeFirstPool();
299+
}
300+
301+
function _getV3RouteInputCurrency(bytes memory path) internal pure returns (address tokenIn) {
302+
if (path.length == 0) {
303+
// if no path, then input currency is eth
304+
return address(0);
305+
}
306+
307+
// Use Path library to get the input token (first token in the path)
308+
(tokenIn, , ) = path.decodeFirstPool();
309+
}
310+
}

packages/coins/src/utils/AutoSwapper.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ pragma solidity ^0.8.28;
99

1010
import {ISwapRouter} from "@zoralabs/shared-contracts/interfaces/uniswap/ISwapRouter.sol";
1111
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
12-
import {Path} from "./uniswap/Path.sol";
12+
import {Path} from "@zoralabs/shared-contracts/libs/UniswapV3/Path.sol";
1313

1414
/// @title AutoSwapper
1515
/// @notice A contract that allows for swapping of tokens via a uniswap v3 swap router. Only works with Uniswap V3 swaps.

0 commit comments

Comments
 (0)