diff --git a/.gitignore b/.gitignore index 99576ac0..13730d59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ artifacts/ node_modules/ typechain/ -cache/ \ No newline at end of file +cache/ +foundry-out/ +lib/ diff --git a/contracts/hooks/GeomeanOracle.sol b/contracts/hooks/GeomeanOracle.sol index b408f5ac..f520cf7d 100644 --- a/contracts/hooks/GeomeanOracle.sol +++ b/contracts/hooks/GeomeanOracle.sol @@ -2,6 +2,7 @@ pragma solidity =0.8.13; import {IPoolManager} from '@uniswap/core-next/contracts/interfaces/IPoolManager.sol'; +import {PoolId} from '@uniswap/core-next/contracts/libraries/PoolId.sol'; import {Hooks} from '@uniswap/core-next/contracts/libraries/Hooks.sol'; import {TickMath} from '@uniswap/core-next/contracts/libraries/TickMath.sol'; import {Oracle} from '@uniswap/core-next/contracts/libraries/Oracle.sol'; @@ -12,6 +13,7 @@ import {BaseHook} from '@uniswap/core-next/contracts/hooks/base/BaseHook.sol'; /// for protocols that wish to use a V3 style geomean oracle. contract GeomeanOracle is BaseHook { using Oracle for Oracle.Observation[65535]; + using PoolId for IPoolManager.PoolKey; /// @notice Oracle pools do not have fees because they exist to serve as an oracle for a pair of tokens error OnlyOneOraclePoolAllowed(); @@ -75,11 +77,12 @@ contract GeomeanOracle is BaseHook { address, IPoolManager.PoolKey calldata key, uint160 - ) external view override poolManagerOnly { + ) external view override poolManagerOnly returns (bytes4) { // This is to limit the fragmentation of pools using this oracle hook. In other words, // there may only be one pool per pair of tokens that use this hook. The tick spacing is set to the maximum // because we only allow max range liquidity in this pool. if (key.fee != 0 || key.tickSpacing != poolManager.MAX_TICK_SPACING()) revert OnlyOneOraclePoolAllowed(); + return GeomeanOracle.beforeInitialize.selector; } function afterInitialize( @@ -87,18 +90,18 @@ contract GeomeanOracle is BaseHook { IPoolManager.PoolKey calldata key, uint160, int24 - ) external override poolManagerOnly { - bytes32 id = keccak256(abi.encode(key)); + ) external override poolManagerOnly returns (bytes4) { + bytes32 id = key.toId(); (states[id].cardinality, states[id].cardinalityNext) = observations[id].initialize(_blockTimestamp()); + return GeomeanOracle.afterInitialize.selector; } /// @dev Called before any action that potentially modifies pool price or liquidity, such as swap or modify position function _updatePool(IPoolManager.PoolKey calldata key) private { - (, int24 tick) = poolManager.getSlot0(key); - - uint128 liquidity = poolManager.getLiquidity(key); + bytes32 id = key.toId(); + (, int24 tick, ) = poolManager.getSlot0(id); - bytes32 id = keccak256(abi.encode(key)); + uint128 liquidity = poolManager.getLiquidity(id); (states[id].index, states[id].cardinality) = observations[id].write( states[id].index, @@ -114,7 +117,7 @@ contract GeomeanOracle is BaseHook { address, IPoolManager.PoolKey calldata key, IPoolManager.ModifyPositionParams calldata params - ) external override poolManagerOnly { + ) external override poolManagerOnly returns (bytes4) { if (params.liquidityDelta < 0) revert OraclePoolMustLockLiquidity(); int24 maxTickSpacing = poolManager.MAX_TICK_SPACING(); if ( @@ -122,14 +125,16 @@ contract GeomeanOracle is BaseHook { params.tickUpper != TickMath.maxUsableTick(maxTickSpacing) ) revert OraclePositionsMustBeFullRange(); _updatePool(key); + return GeomeanOracle.beforeModifyPosition.selector; } function beforeSwap( address, IPoolManager.PoolKey calldata key, IPoolManager.SwapParams calldata - ) external override poolManagerOnly { + ) external override poolManagerOnly returns (bytes4) { _updatePool(key); + return GeomeanOracle.beforeSwap.selector; } /// @notice Observe the given pool for the timestamps @@ -138,13 +143,13 @@ contract GeomeanOracle is BaseHook { view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) { - bytes32 id = keccak256(abi.encode(key)); + bytes32 id = key.toId(); ObservationState memory state = states[id]; - (, int24 tick) = poolManager.getSlot0(key); + (, int24 tick, ) = poolManager.getSlot0(id); - uint128 liquidity = poolManager.getLiquidity(key); + uint128 liquidity = poolManager.getLiquidity(id); return observations[id].observe(_blockTimestamp(), secondsAgos, tick, state.index, liquidity, state.cardinality); diff --git a/contracts/hooks/LimitOrderHook.sol b/contracts/hooks/LimitOrderHook.sol index d21718bd..9d74ac88 100644 --- a/contracts/hooks/LimitOrderHook.sol +++ b/contracts/hooks/LimitOrderHook.sol @@ -2,6 +2,7 @@ pragma solidity =0.8.13; import {IPoolManager} from '@uniswap/core-next/contracts/interfaces/IPoolManager.sol'; +import {PoolId} from '@uniswap/core-next/contracts/libraries/PoolId.sol'; import {Hooks} from '@uniswap/core-next/contracts/libraries/Hooks.sol'; import {FullMath} from '@uniswap/core-next/contracts/libraries/FullMath.sol'; import {SafeCast} from '@uniswap/core-next/contracts/libraries/SafeCast.sol'; @@ -27,6 +28,7 @@ library EpochLibrary { contract LimitOrderHook is BaseHook { using SafeCast for uint256; using EpochLibrary for Epoch; + using PoolId for IPoolManager.PoolKey; error ZeroLiquidity(); error InRange(); @@ -91,12 +93,12 @@ contract LimitOrderHook is BaseHook { ); } - function getTickLowerLast(IPoolManager.PoolKey memory key) public view returns (int24) { - return tickLowerLasts[keccak256(abi.encode(key))]; + function getTickLowerLast(bytes32 poolId) public view returns (int24) { + return tickLowerLasts[poolId]; } - function setTickLowerLast(IPoolManager.PoolKey memory key, int24 tickLower) private { - tickLowerLasts[keccak256(abi.encode(key))] = tickLower; + function setTickLowerLast(bytes32 poolId, int24 tickLower) private { + tickLowerLasts[poolId] = tickLower; } function getEpoch( @@ -120,8 +122,8 @@ contract LimitOrderHook is BaseHook { return epochInfos[epoch].liquidity[owner]; } - function getTick(IPoolManager.PoolKey memory key) private view returns (int24 tick) { - (, tick) = poolManager.getSlot0(key); + function getTick(bytes32 poolId) private view returns (int24 tick) { + (, tick, ) = poolManager.getSlot0(poolId); } function getTickLower(int24 tick, int24 tickSpacing) private pure returns (int24) { @@ -135,8 +137,9 @@ contract LimitOrderHook is BaseHook { IPoolManager.PoolKey calldata key, uint160, int24 tick - ) external override poolManagerOnly { - setTickLowerLast(key, getTickLower(tick, key.tickSpacing)); + ) external override poolManagerOnly returns (bytes4) { + setTickLowerLast(key.toId(), getTickLower(tick, key.tickSpacing)); + return LimitOrderHook.afterInitialize.selector; } function afterSwap( @@ -144,22 +147,9 @@ contract LimitOrderHook is BaseHook { IPoolManager.PoolKey calldata key, IPoolManager.SwapParams calldata params, IPoolManager.BalanceDelta calldata - ) external override poolManagerOnly { - int24 tickLower = getTickLower(getTick(key), key.tickSpacing); - int24 tickLowerLast = getTickLowerLast(key); - if (tickLower == tickLowerLast) return; - - int24 lower; - int24 upper; - if (tickLower < tickLowerLast) { - // the pool has moved "left", meaning it's traded token1 for token0, - lower = tickLower + key.tickSpacing; - upper = tickLowerLast; - } else { - // the pool has moved "right", meaning it's traded token0 for token1 - lower = tickLowerLast; - upper = tickLower - key.tickSpacing; - } + ) external override poolManagerOnly returns (bytes4) { + (int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing); + if (lower > upper) return LimitOrderHook.afterSwap.selector; // note that a zeroForOne swap means that the pool is actually gaining token0, so limit // order fills are the opposite of swap fills, hence the inversion below @@ -189,7 +179,21 @@ contract LimitOrderHook is BaseHook { } } - setTickLowerLast(key, tickLower); + setTickLowerLast(key.toId(), tickLower); + return LimitOrderHook.afterSwap.selector; + } + + function _getCrossedTicks(bytes32 poolId, int24 tickSpacing) internal view returns (int24 tickLower, int24 lower, int24 upper) { + tickLower = getTickLower(getTick(poolId), tickSpacing); + int24 tickLowerLast = getTickLowerLast(poolId); + + if (tickLower < tickLowerLast) { + lower = tickLower + tickSpacing; + upper = tickLowerLast; + } else { + lower = tickLowerLast; + upper = tickLower - tickSpacing; + } } function lockAcquiredFill( diff --git a/package.json b/package.json index 40225f40..8edfb997 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@openzeppelin/contracts": "4.4.2", - "@uniswap/core-next": "git+ssh://git@github.com:Uniswap/core-next.git#43df9917ba606b151420dacfa9a08908256085f8" + "@uniswap/core-next": "git+ssh://git@github.com:Uniswap/core-next.git#50d7873b95218df47bc747d1218a97a29fad494e" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "^2.0.2", diff --git a/test/GeomeanOracle.spec.ts b/test/GeomeanOracle.spec.ts index a6e0cbe2..e617af85 100644 --- a/test/GeomeanOracle.spec.ts +++ b/test/GeomeanOracle.spec.ts @@ -9,7 +9,7 @@ import { MockTimeGeomeanOracle, IPoolManager, PoolModifyPositionTest, TestERC20 import { MAX_TICK_SPACING } from './shared/constants' import { expect } from './shared/expect' import { tokensFixture } from './shared/fixtures' -import { createHookMask, encodeSqrtPriceX96, getMaxTick, getMinTick } from './shared/utilities' +import { createHookMask, encodeSqrtPriceX96, getMaxTick, getMinTick, getPoolId } from './shared/utilities' describe('GeomeanOracle', () => { let wallets: Wallet[] @@ -47,7 +47,7 @@ describe('GeomeanOracle', () => { return (await waffle.deployContract(wallet, { bytecode: V4_POOL_MANAGER_BYTECODE, abi: V4_POOL_MANAGER_ABI, - })) as IPoolManager + }, [10000])) as IPoolManager } const fixture = async ([wallet]: Wallet[]) => { @@ -126,7 +126,7 @@ describe('GeomeanOracle', () => { let snapshotId: string beforeEach('check the pool is not initialized', async () => { - const { sqrtPriceX96 } = await poolManager.getSlot0(poolKey) + const { sqrtPriceX96 } = await poolManager.getSlot0(getPoolId(poolKey)) expect(sqrtPriceX96, 'pool is not initialized').to.eq(0) // it seems like the waffle fixture is not working correctly (perhaps due to hardhat_setCode), and if we don't do this and revert in afterEach, the pool is already initialized snapshotId = await hre.network.provider.send('evm_snapshot') diff --git a/test/LimitOrderHook.spec.ts b/test/LimitOrderHook.spec.ts index 1175f2dc..e4bfc93b 100644 --- a/test/LimitOrderHook.spec.ts +++ b/test/LimitOrderHook.spec.ts @@ -1,3 +1,4 @@ +import snapshotGasCost from '@uniswap/snapshot-gas-cost'; import { abi as V4_POOL_MANAGER_ABI, bytecode as V4_POOL_MANAGER_BYTECODE, @@ -16,7 +17,7 @@ import hre, { ethers, waffle } from 'hardhat' import { LimitOrderHook, TestERC20 } from '../typechain' import { expect } from './shared/expect' import { tokensFixture } from './shared/fixtures' -import { encodeSqrtPriceX96, expandTo18Decimals, FeeAmount, getWalletForDeployingHookMask } from './shared/utilities' +import { encodeSqrtPriceX96, expandTo18Decimals, FeeAmount, getWalletForDeployingHookMask, getPoolId } from './shared/utilities' const { constants } = ethers @@ -34,7 +35,7 @@ const v4PoolManagerFixure = async ([wallet]: Wallet[]) => { return (await waffle.deployContract(wallet, { bytecode: V4_POOL_MANAGER_BYTECODE, abi: V4_POOL_MANAGER_ABI, - })) as PoolManager + }, [10000])) as PoolManager } const poolSwapTestFixture = async ([wallet]: Wallet[], manager: string) => { @@ -149,7 +150,7 @@ describe('LimitOrderHooks', () => { describe('hook is initialized', async () => { describe('#getTickLowerLast', () => { it('works when the price is 1', async () => { - expect(await limitOrderHook.getTickLowerLast(key)).to.eq(0) + expect(await limitOrderHook.getTickLowerLast(getPoolId(key))).to.eq(0) }) it('works when the price is not 1', async () => { @@ -158,7 +159,7 @@ describe('LimitOrderHooks', () => { tickSpacing: 61, } await manager.initialize(otherKey, encodeSqrtPriceX96(10, 1)) - expect(await limitOrderHook.getTickLowerLast(otherKey)).to.eq(22997) + expect(await limitOrderHook.getTickLowerLast(getPoolId(otherKey))).to.eq(22997) }) }) @@ -184,8 +185,8 @@ describe('LimitOrderHooks', () => { await limitOrderHook.place(key, tickLower, zeroForOne, liquidity) expect(await limitOrderHook.getEpoch(key, tickLower, zeroForOne)).to.eq(1) expect( - await manager['getLiquidity((address,address,uint24,int24,address),address,int24,int24)']( - key, + await manager['getLiquidity(bytes32,address,int24,int24)']( + getPoolId(key), limitOrderHook.address, tickLower, tickLower + key.tickSpacing @@ -198,8 +199,8 @@ describe('LimitOrderHooks', () => { await limitOrderHook.place(key, tickLower, zeroForOne, liquidity) expect(await limitOrderHook.getEpoch(key, tickLower, zeroForOne)).to.eq(1) expect( - await manager['getLiquidity((address,address,uint24,int24,address),address,int24,int24)']( - key, + await manager['getLiquidity(bytes32,address,int24,int24)']( + getPoolId(key), limitOrderHook.address, tickLower, tickLower + key.tickSpacing @@ -240,8 +241,8 @@ describe('LimitOrderHooks', () => { await limitOrderHook.place(key, tickLower, zeroForOne, liquidity) expect(await limitOrderHook.getEpoch(key, tickLower, zeroForOne)).to.eq(1) expect( - await manager['getLiquidity((address,address,uint24,int24,address),address,int24,int24)']( - key, + await manager['getLiquidity(bytes32,address,int24,int24)']( + getPoolId(key), limitOrderHook.address, tickLower, tickLower + key.tickSpacing @@ -282,8 +283,8 @@ describe('LimitOrderHooks', () => { expect(await limitOrderHook.getEpoch(key, tickLower, zeroForOne)).to.eq(1) expect( - await manager['getLiquidity((address,address,uint24,int24,address),address,int24,int24)']( - key, + await manager['getLiquidity(bytes32,address,int24,int24)']( + getPoolId(key), limitOrderHook.address, tickLower, tickLower + key.tickSpacing @@ -319,6 +320,10 @@ describe('LimitOrderHooks', () => { expect(await limitOrderHook.getEpochLiquidity(1, wallet.address)).to.eq(0) }) + + it('gas cost', async () => { + await snapshotGasCost(limitOrderHook.kill(key, tickLower, zeroForOne, wallet.address)); + }) }) describe('swap across the range', async () => { @@ -353,9 +358,9 @@ describe('LimitOrderHooks', () => { .to.emit(tokens.token0, 'Transfer') .withArgs(manager.address, wallet.address, expectedToken0Amount - 1) // 1 wei of dust - expect(await limitOrderHook.getTickLowerLast(key)).to.be.eq(key.tickSpacing) + expect(await limitOrderHook.getTickLowerLast(getPoolId(key))).to.be.eq(key.tickSpacing) - expect((await manager.getSlot0(key)).tick).to.eq(key.tickSpacing) + expect((await manager.getSlot0(getPoolId(key))).tick).to.eq(key.tickSpacing) }) it('#fill', async () => { @@ -366,8 +371,8 @@ describe('LimitOrderHooks', () => { expect(epochInfo.token1Total).to.eq(expectedToken0Amount + 17) // 3013, 2 wei of dust expect( - await manager['getLiquidity((address,address,uint24,int24,address),address,int24,int24)']( - key, + await manager['getLiquidity(bytes32,address,int24,int24)']( + getPoolId(key), limitOrderHook.address, tickLower, tickLower + key.tickSpacing @@ -386,4 +391,34 @@ describe('LimitOrderHooks', () => { expect(epochInfo.token1Total).to.eq(0) }) }) + + describe('#afterSwap', async () => { + const tickLower = 0 + const zeroForOne = true + const liquidity = 1000000 + const expectedToken0Amount = 2996 + + beforeEach('create limit order', async () => { + await expect(limitOrderHook.place(key, tickLower, zeroForOne, liquidity)) + .to.emit(tokens.token0, 'Transfer') + .withArgs(wallet.address, manager.address, expectedToken0Amount) + }) + + it('gas cost', async () => { + await snapshotGasCost( + swapTest.swap( + key, + { + zeroForOne: false, + amountSpecified: expandTo18Decimals(1), + sqrtPriceLimitX96: await tickMath.getSqrtRatioAtTick(key.tickSpacing), + }, + { + withdrawTokens: true, + settleUsingTransfer: true, + } + ) + ) + }) + }) }) diff --git a/test/__snapshots__/LimitOrderHook.spec.ts.snap b/test/__snapshots__/LimitOrderHook.spec.ts.snap index 87a011e4..d5bfc93e 100644 --- a/test/__snapshots__/LimitOrderHook.spec.ts.snap +++ b/test/__snapshots__/LimitOrderHook.spec.ts.snap @@ -1,3 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LimitOrderHooks bytecode size 1`] = `13038`; +exports[`LimitOrderHooks #afterSwap gas cost 1`] = ` +Object { + "calldataByteLength": 324, + "gasUsed": 506798, +} +`; + +exports[`LimitOrderHooks #kill gas cost 1`] = ` +Object { + "calldataByteLength": 260, + "gasUsed": 210040, +} +`; + +exports[`LimitOrderHooks bytecode size 1`] = `13098`; diff --git a/yarn.lock b/yarn.lock index 1d0f86fc..8fadad77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1253,9 +1253,9 @@ resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== -"@uniswap/core-next@git+ssh://git@github.com:Uniswap/core-next.git#43df9917ba606b151420dacfa9a08908256085f8": +"@uniswap/core-next@git+ssh://git@github.com:Uniswap/core-next.git#50d7873b95218df47bc747d1218a97a29fad494e": version "1.0.0" - resolved "git+ssh://git@github.com:Uniswap/core-next.git#43df9917ba606b151420dacfa9a08908256085f8" + resolved "git+ssh://git@github.com:Uniswap/core-next.git#50d7873b95218df47bc747d1218a97a29fad494e" dependencies: "@openzeppelin/contracts" "4.4.2"