├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── .mocharc.json ├── .prettierignore ├── .prettierrc ├── .solcover.js ├── .solhint.json ├── .yarnrc ├── LICENSE ├── README.md ├── contracts ├── UniswapV3Staker.sol ├── interfaces │ └── IUniswapV3Staker.sol ├── libraries │ ├── IncentiveId.sol │ ├── NFTPositionInfo.sol │ ├── RewardMath.sol │ └── TransferHelperExtended.sol └── test │ ├── TestERC20.sol │ ├── TestIncentiveId.sol │ └── TestRewardMath.sol ├── docs └── Design.md ├── hardhat.config.ts ├── package.json ├── test ├── UniswapV3Staker.integration.spec.ts ├── helpers │ ├── index.ts │ └── types.ts ├── matchers │ └── beWithin.ts ├── shared │ ├── actors.ts │ ├── external │ │ ├── WETH9.json │ │ └── v3-periphery │ │ │ ├── constants.ts │ │ │ ├── ticks.ts │ │ │ └── tokenSort.ts │ ├── fixtures.ts │ ├── index.ts │ ├── linkLibraries.ts │ ├── logging.ts │ ├── provider.ts │ ├── ticks.ts │ └── time.ts ├── types.ts └── unit │ ├── Deployment.spec.ts │ ├── Deposits.spec.ts │ ├── Incentives.spec.ts │ ├── Multicall.spec.ts │ ├── RewardMath │ └── RewardMath.spec.ts │ ├── Stakes.spec.ts │ └── __snapshots__ │ ├── Deposits.spec.ts.snap │ ├── Incentives.spec.ts.snap │ ├── Multicall.spec.ts.snap │ └── Stakes.spec.ts.snap ├── tsconfig.json ├── types ├── ISwapRouter.d.ts ├── IWETH9.d.ts ├── NFTDescriptor.d.ts └── contractParams.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | contracts 5 | typechain 6 | test/shared/external/v3-periphery/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | 'unused-imports', 7 | ], 8 | extends: [ 9 | // 'eslint:recommended', 10 | // 'plugin:@typescript-eslint/recommended', 11 | 'prettier' 12 | ], 13 | rules: { 14 | "@typescript-eslint/no-unused-vars": 1, 15 | "unused-imports/no-unused-imports-ts": 1, 16 | "no-shadow": 1 17 | } 18 | }; -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sol linguist-language=Solidity -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | prettier: 11 | name: Prettier 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.x 19 | registry-url: https://registry.npmjs.org 20 | 21 | - id: yarn-cache 22 | run: echo "::set-output name=dir::$(yarn cache dir)" 23 | 24 | - uses: actions/cache@v1 25 | with: 26 | path: ${{ steps.yarn-cache.outputs.dir }} 27 | key: yarn-${{ hashFiles('**/yarn.lock') }} 28 | restore-keys: | 29 | yarn- 30 | 31 | - name: Install dependencies 32 | run: yarn install --frozen-lockfile 33 | 34 | - name: Lint 35 | run: yarn prettier:check 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | unit-tests: 11 | name: Unit Tests 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.x 19 | registry-url: https://registry.npmjs.org 20 | 21 | - id: yarn-cache 22 | run: echo "::set-output name=dir::$(yarn cache dir)" 23 | 24 | - uses: actions/cache@v1 25 | with: 26 | path: ${{ steps.yarn-cache.outputs.dir }} 27 | key: yarn-${{ hashFiles('**/yarn.lock') }} 28 | restore-keys: | 29 | yarn- 30 | 31 | - name: Install dependencies 32 | run: yarn install --frozen-lockfile 33 | 34 | # This is required separately from yarn test because it generates the typechain definitions 35 | - name: Compile 36 | run: yarn compile 37 | 38 | # - name: Compile Uniswap V3 types 39 | # run: yarn compile-uniswap-contract-types 40 | 41 | - name: Run tests 42 | run: yarn test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | artifacts/ 2 | cache/ 3 | crytic-export/ 4 | node_modules/ 5 | typechain/ 6 | *.code-workspace 7 | dist/ 8 | coverage/ -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register/files", 3 | "timeout": 1000000 4 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | skipFiles: [ 3 | "contracts/test/TestERC20.sol" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier"], 3 | "rules": { 4 | "prettier/prettier": "error" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | ignore-scripts true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uniswap-v3-staker 2 | 3 | This is the canonical staking contract designed for [Uniswap V3](https://github.com/Uniswap/uniswap-v3-core). 4 | 5 | ## Deployments 6 | 7 | Note that the v1.0.0 release is susceptible to a [high-difficulty, never-exploited vulnerability](https://github.com/Uniswap/v3-staker/issues/219). For this reason, please use the v1.0.2 release, deployed and verified on Etherscan: 8 | 9 | | Network | Explorer | 10 | | ---------------- | ---------------------------------------------------------------------------------------- | 11 | | Mainnet | | 12 | | Arbitrum One | | 13 | | Optimism | | 14 | | Base | | 15 | 16 | ⚠️DEPRECATED⚠️: For historical verification purposes only, the staker at tag v1.0.0 was deployed at the address: `0x1f98407aaB862CdDeF78Ed252D6f557aA5b0f00d` 17 | 18 | ## Links 19 | 20 | - [Contract Design](docs/Design.md) 21 | 22 | ## Development and Testing 23 | 24 | ```sh 25 | yarn 26 | yarn test 27 | ``` 28 | 29 | ## Gas Snapshots 30 | 31 | ```sh 32 | # if gas snapshots need to be updated 33 | $ UPDATE_SNAPSHOT=1 yarn test 34 | ``` 35 | 36 | ## Contract Sizing 37 | 38 | ```sh 39 | yarn size-contracts 40 | ``` 41 | -------------------------------------------------------------------------------- /contracts/UniswapV3Staker.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | pragma abicoder v2; 4 | 5 | import './interfaces/IUniswapV3Staker.sol'; 6 | import './libraries/IncentiveId.sol'; 7 | import './libraries/RewardMath.sol'; 8 | import './libraries/NFTPositionInfo.sol'; 9 | import './libraries/TransferHelperExtended.sol'; 10 | 11 | import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol'; 12 | import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol'; 13 | import '@uniswap/v3-core/contracts/interfaces/IERC20Minimal.sol'; 14 | 15 | import '@uniswap/v3-periphery/contracts/interfaces/INonfungiblePositionManager.sol'; 16 | import '@uniswap/v3-periphery/contracts/base/Multicall.sol'; 17 | 18 | /// @title Uniswap V3 canonical staking interface 19 | contract UniswapV3Staker is IUniswapV3Staker, Multicall { 20 | /// @notice Represents a staking incentive 21 | struct Incentive { 22 | uint256 totalRewardUnclaimed; 23 | uint160 totalSecondsClaimedX128; 24 | uint96 numberOfStakes; 25 | } 26 | 27 | /// @notice Represents the deposit of a liquidity NFT 28 | struct Deposit { 29 | address owner; 30 | uint48 numberOfStakes; 31 | int24 tickLower; 32 | int24 tickUpper; 33 | } 34 | 35 | /// @notice Represents a staked liquidity NFT 36 | struct Stake { 37 | uint160 secondsPerLiquidityInsideInitialX128; 38 | uint96 liquidityNoOverflow; 39 | uint128 liquidityIfOverflow; 40 | } 41 | 42 | /// @inheritdoc IUniswapV3Staker 43 | IUniswapV3Factory public immutable override factory; 44 | /// @inheritdoc IUniswapV3Staker 45 | INonfungiblePositionManager public immutable override nonfungiblePositionManager; 46 | 47 | /// @inheritdoc IUniswapV3Staker 48 | uint256 public immutable override maxIncentiveStartLeadTime; 49 | /// @inheritdoc IUniswapV3Staker 50 | uint256 public immutable override maxIncentiveDuration; 51 | 52 | /// @dev bytes32 refers to the return value of IncentiveId.compute 53 | mapping(bytes32 => Incentive) public override incentives; 54 | 55 | /// @dev deposits[tokenId] => Deposit 56 | mapping(uint256 => Deposit) public override deposits; 57 | 58 | /// @dev stakes[tokenId][incentiveHash] => Stake 59 | mapping(uint256 => mapping(bytes32 => Stake)) private _stakes; 60 | 61 | /// @inheritdoc IUniswapV3Staker 62 | function stakes(uint256 tokenId, bytes32 incentiveId) 63 | public 64 | view 65 | override 66 | returns (uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity) 67 | { 68 | Stake storage stake = _stakes[tokenId][incentiveId]; 69 | secondsPerLiquidityInsideInitialX128 = stake.secondsPerLiquidityInsideInitialX128; 70 | liquidity = stake.liquidityNoOverflow; 71 | if (liquidity == type(uint96).max) { 72 | liquidity = stake.liquidityIfOverflow; 73 | } 74 | } 75 | 76 | /// @dev rewards[rewardToken][owner] => uint256 77 | /// @inheritdoc IUniswapV3Staker 78 | mapping(IERC20Minimal => mapping(address => uint256)) public override rewards; 79 | 80 | /// @param _factory the Uniswap V3 factory 81 | /// @param _nonfungiblePositionManager the NFT position manager contract address 82 | /// @param _maxIncentiveStartLeadTime the max duration of an incentive in seconds 83 | /// @param _maxIncentiveDuration the max amount of seconds into the future the incentive startTime can be set 84 | constructor( 85 | IUniswapV3Factory _factory, 86 | INonfungiblePositionManager _nonfungiblePositionManager, 87 | uint256 _maxIncentiveStartLeadTime, 88 | uint256 _maxIncentiveDuration 89 | ) { 90 | factory = _factory; 91 | nonfungiblePositionManager = _nonfungiblePositionManager; 92 | maxIncentiveStartLeadTime = _maxIncentiveStartLeadTime; 93 | maxIncentiveDuration = _maxIncentiveDuration; 94 | } 95 | 96 | /// @inheritdoc IUniswapV3Staker 97 | function createIncentive(IncentiveKey memory key, uint256 reward) external override { 98 | require(reward > 0, 'UniswapV3Staker::createIncentive: reward must be positive'); 99 | require( 100 | block.timestamp <= key.startTime, 101 | 'UniswapV3Staker::createIncentive: start time must be now or in the future' 102 | ); 103 | require( 104 | key.startTime - block.timestamp <= maxIncentiveStartLeadTime, 105 | 'UniswapV3Staker::createIncentive: start time too far into future' 106 | ); 107 | require(key.startTime < key.endTime, 'UniswapV3Staker::createIncentive: start time must be before end time'); 108 | require( 109 | key.endTime - key.startTime <= maxIncentiveDuration, 110 | 'UniswapV3Staker::createIncentive: incentive duration is too long' 111 | ); 112 | 113 | bytes32 incentiveId = IncentiveId.compute(key); 114 | 115 | incentives[incentiveId].totalRewardUnclaimed += reward; 116 | 117 | TransferHelperExtended.safeTransferFrom(address(key.rewardToken), msg.sender, address(this), reward); 118 | 119 | emit IncentiveCreated(key.rewardToken, key.pool, key.startTime, key.endTime, key.refundee, reward); 120 | } 121 | 122 | /// @inheritdoc IUniswapV3Staker 123 | function endIncentive(IncentiveKey memory key) external override returns (uint256 refund) { 124 | require(block.timestamp >= key.endTime, 'UniswapV3Staker::endIncentive: cannot end incentive before end time'); 125 | 126 | bytes32 incentiveId = IncentiveId.compute(key); 127 | Incentive storage incentive = incentives[incentiveId]; 128 | 129 | refund = incentive.totalRewardUnclaimed; 130 | 131 | require(refund > 0, 'UniswapV3Staker::endIncentive: no refund available'); 132 | require( 133 | incentive.numberOfStakes == 0, 134 | 'UniswapV3Staker::endIncentive: cannot end incentive while deposits are staked' 135 | ); 136 | 137 | // issue the refund 138 | incentive.totalRewardUnclaimed = 0; 139 | TransferHelperExtended.safeTransfer(address(key.rewardToken), key.refundee, refund); 140 | 141 | // note we never clear totalSecondsClaimedX128 142 | 143 | emit IncentiveEnded(incentiveId, refund); 144 | } 145 | 146 | /// @notice Upon receiving a Uniswap V3 ERC721, creates the token deposit setting owner to `from`. Also stakes token 147 | /// in one or more incentives if properly formatted `data` has a length > 0. 148 | /// @inheritdoc IERC721Receiver 149 | function onERC721Received( 150 | address, 151 | address from, 152 | uint256 tokenId, 153 | bytes calldata data 154 | ) external override returns (bytes4) { 155 | require( 156 | msg.sender == address(nonfungiblePositionManager), 157 | 'UniswapV3Staker::onERC721Received: not a univ3 nft' 158 | ); 159 | 160 | (, , , , , int24 tickLower, int24 tickUpper, , , , , ) = nonfungiblePositionManager.positions(tokenId); 161 | 162 | deposits[tokenId] = Deposit({owner: from, numberOfStakes: 0, tickLower: tickLower, tickUpper: tickUpper}); 163 | emit DepositTransferred(tokenId, address(0), from); 164 | 165 | if (data.length > 0) { 166 | if (data.length == 160) { 167 | _stakeToken(abi.decode(data, (IncentiveKey)), tokenId); 168 | } else { 169 | IncentiveKey[] memory keys = abi.decode(data, (IncentiveKey[])); 170 | for (uint256 i = 0; i < keys.length; i++) { 171 | _stakeToken(keys[i], tokenId); 172 | } 173 | } 174 | } 175 | return this.onERC721Received.selector; 176 | } 177 | 178 | /// @inheritdoc IUniswapV3Staker 179 | function transferDeposit(uint256 tokenId, address to) external override { 180 | require(to != address(0), 'UniswapV3Staker::transferDeposit: invalid transfer recipient'); 181 | address owner = deposits[tokenId].owner; 182 | require(owner == msg.sender, 'UniswapV3Staker::transferDeposit: can only be called by deposit owner'); 183 | deposits[tokenId].owner = to; 184 | emit DepositTransferred(tokenId, owner, to); 185 | } 186 | 187 | /// @inheritdoc IUniswapV3Staker 188 | function withdrawToken( 189 | uint256 tokenId, 190 | address to, 191 | bytes memory data 192 | ) external override { 193 | require(to != address(this), 'UniswapV3Staker::withdrawToken: cannot withdraw to staker'); 194 | Deposit memory deposit = deposits[tokenId]; 195 | require(deposit.numberOfStakes == 0, 'UniswapV3Staker::withdrawToken: cannot withdraw token while staked'); 196 | require(deposit.owner == msg.sender, 'UniswapV3Staker::withdrawToken: only owner can withdraw token'); 197 | 198 | delete deposits[tokenId]; 199 | emit DepositTransferred(tokenId, deposit.owner, address(0)); 200 | 201 | nonfungiblePositionManager.safeTransferFrom(address(this), to, tokenId, data); 202 | } 203 | 204 | /// @inheritdoc IUniswapV3Staker 205 | function stakeToken(IncentiveKey memory key, uint256 tokenId) external override { 206 | require(deposits[tokenId].owner == msg.sender, 'UniswapV3Staker::stakeToken: only owner can stake token'); 207 | 208 | _stakeToken(key, tokenId); 209 | } 210 | 211 | /// @inheritdoc IUniswapV3Staker 212 | function unstakeToken(IncentiveKey memory key, uint256 tokenId) external override { 213 | Deposit memory deposit = deposits[tokenId]; 214 | // anyone can call unstakeToken if the block time is after the end time of the incentive 215 | if (block.timestamp < key.endTime) { 216 | require( 217 | deposit.owner == msg.sender, 218 | 'UniswapV3Staker::unstakeToken: only owner can withdraw token before incentive end time' 219 | ); 220 | } 221 | 222 | bytes32 incentiveId = IncentiveId.compute(key); 223 | 224 | (uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity) = stakes(tokenId, incentiveId); 225 | 226 | require(liquidity != 0, 'UniswapV3Staker::unstakeToken: stake does not exist'); 227 | 228 | Incentive storage incentive = incentives[incentiveId]; 229 | 230 | deposits[tokenId].numberOfStakes--; 231 | incentive.numberOfStakes--; 232 | 233 | (, uint160 secondsPerLiquidityInsideX128, ) = 234 | key.pool.snapshotCumulativesInside(deposit.tickLower, deposit.tickUpper); 235 | (uint256 reward, uint160 secondsInsideX128) = 236 | RewardMath.computeRewardAmount( 237 | incentive.totalRewardUnclaimed, 238 | incentive.totalSecondsClaimedX128, 239 | key.startTime, 240 | key.endTime, 241 | liquidity, 242 | secondsPerLiquidityInsideInitialX128, 243 | secondsPerLiquidityInsideX128, 244 | block.timestamp 245 | ); 246 | 247 | // if this overflows, e.g. after 2^32-1 full liquidity seconds have been claimed, 248 | // reward rate will fall drastically so it's safe 249 | incentive.totalSecondsClaimedX128 += secondsInsideX128; 250 | // reward is never greater than total reward unclaimed 251 | incentive.totalRewardUnclaimed -= reward; 252 | // this only overflows if a token has a total supply greater than type(uint256).max 253 | rewards[key.rewardToken][deposit.owner] += reward; 254 | 255 | Stake storage stake = _stakes[tokenId][incentiveId]; 256 | delete stake.secondsPerLiquidityInsideInitialX128; 257 | delete stake.liquidityNoOverflow; 258 | if (liquidity >= type(uint96).max) delete stake.liquidityIfOverflow; 259 | emit TokenUnstaked(tokenId, incentiveId); 260 | } 261 | 262 | /// @inheritdoc IUniswapV3Staker 263 | function claimReward( 264 | IERC20Minimal rewardToken, 265 | address to, 266 | uint256 amountRequested 267 | ) external override returns (uint256 reward) { 268 | reward = rewards[rewardToken][msg.sender]; 269 | if (amountRequested != 0 && amountRequested < reward) { 270 | reward = amountRequested; 271 | } 272 | 273 | rewards[rewardToken][msg.sender] -= reward; 274 | TransferHelperExtended.safeTransfer(address(rewardToken), to, reward); 275 | 276 | emit RewardClaimed(to, reward); 277 | } 278 | 279 | /// @inheritdoc IUniswapV3Staker 280 | function getRewardInfo(IncentiveKey memory key, uint256 tokenId) 281 | external 282 | view 283 | override 284 | returns (uint256 reward, uint160 secondsInsideX128) 285 | { 286 | bytes32 incentiveId = IncentiveId.compute(key); 287 | 288 | (uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity) = stakes(tokenId, incentiveId); 289 | require(liquidity > 0, 'UniswapV3Staker::getRewardInfo: stake does not exist'); 290 | 291 | Deposit memory deposit = deposits[tokenId]; 292 | Incentive memory incentive = incentives[incentiveId]; 293 | 294 | (, uint160 secondsPerLiquidityInsideX128, ) = 295 | key.pool.snapshotCumulativesInside(deposit.tickLower, deposit.tickUpper); 296 | 297 | (reward, secondsInsideX128) = RewardMath.computeRewardAmount( 298 | incentive.totalRewardUnclaimed, 299 | incentive.totalSecondsClaimedX128, 300 | key.startTime, 301 | key.endTime, 302 | liquidity, 303 | secondsPerLiquidityInsideInitialX128, 304 | secondsPerLiquidityInsideX128, 305 | block.timestamp 306 | ); 307 | } 308 | 309 | /// @dev Stakes a deposited token without doing an ownership check 310 | function _stakeToken(IncentiveKey memory key, uint256 tokenId) private { 311 | require(block.timestamp >= key.startTime, 'UniswapV3Staker::stakeToken: incentive not started'); 312 | require(block.timestamp < key.endTime, 'UniswapV3Staker::stakeToken: incentive ended'); 313 | 314 | bytes32 incentiveId = IncentiveId.compute(key); 315 | 316 | require( 317 | incentives[incentiveId].totalRewardUnclaimed > 0, 318 | 'UniswapV3Staker::stakeToken: non-existent incentive' 319 | ); 320 | require( 321 | _stakes[tokenId][incentiveId].liquidityNoOverflow == 0, 322 | 'UniswapV3Staker::stakeToken: token already staked' 323 | ); 324 | 325 | (IUniswapV3Pool pool, int24 tickLower, int24 tickUpper, uint128 liquidity) = 326 | NFTPositionInfo.getPositionInfo(factory, nonfungiblePositionManager, tokenId); 327 | 328 | require(pool == key.pool, 'UniswapV3Staker::stakeToken: token pool is not the incentive pool'); 329 | require(liquidity > 0, 'UniswapV3Staker::stakeToken: cannot stake token with 0 liquidity'); 330 | 331 | deposits[tokenId].numberOfStakes++; 332 | incentives[incentiveId].numberOfStakes++; 333 | 334 | (, uint160 secondsPerLiquidityInsideX128, ) = pool.snapshotCumulativesInside(tickLower, tickUpper); 335 | 336 | if (liquidity >= type(uint96).max) { 337 | _stakes[tokenId][incentiveId] = Stake({ 338 | secondsPerLiquidityInsideInitialX128: secondsPerLiquidityInsideX128, 339 | liquidityNoOverflow: type(uint96).max, 340 | liquidityIfOverflow: liquidity 341 | }); 342 | } else { 343 | Stake storage stake = _stakes[tokenId][incentiveId]; 344 | stake.secondsPerLiquidityInsideInitialX128 = secondsPerLiquidityInsideX128; 345 | stake.liquidityNoOverflow = uint96(liquidity); 346 | } 347 | 348 | emit TokenStaked(tokenId, incentiveId, liquidity); 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /contracts/interfaces/IUniswapV3Staker.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | pragma abicoder v2; 4 | 5 | import '@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol'; 6 | 7 | import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol'; 8 | import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol'; 9 | import '@uniswap/v3-core/contracts/interfaces/IERC20Minimal.sol'; 10 | 11 | import '@uniswap/v3-periphery/contracts/interfaces/INonfungiblePositionManager.sol'; 12 | import '@uniswap/v3-periphery/contracts/interfaces/IMulticall.sol'; 13 | 14 | /// @title Uniswap V3 Staker Interface 15 | /// @notice Allows staking nonfungible liquidity tokens in exchange for reward tokens 16 | interface IUniswapV3Staker is IERC721Receiver, IMulticall { 17 | /// @param rewardToken The token being distributed as a reward 18 | /// @param pool The Uniswap V3 pool 19 | /// @param startTime The time when the incentive program begins 20 | /// @param endTime The time when rewards stop accruing 21 | /// @param refundee The address which receives any remaining reward tokens when the incentive is ended 22 | struct IncentiveKey { 23 | IERC20Minimal rewardToken; 24 | IUniswapV3Pool pool; 25 | uint256 startTime; 26 | uint256 endTime; 27 | address refundee; 28 | } 29 | 30 | /// @notice The Uniswap V3 Factory 31 | function factory() external view returns (IUniswapV3Factory); 32 | 33 | /// @notice The nonfungible position manager with which this staking contract is compatible 34 | function nonfungiblePositionManager() external view returns (INonfungiblePositionManager); 35 | 36 | /// @notice The max duration of an incentive in seconds 37 | function maxIncentiveDuration() external view returns (uint256); 38 | 39 | /// @notice The max amount of seconds into the future the incentive startTime can be set 40 | function maxIncentiveStartLeadTime() external view returns (uint256); 41 | 42 | /// @notice Represents a staking incentive 43 | /// @param incentiveId The ID of the incentive computed from its parameters 44 | /// @return totalRewardUnclaimed The amount of reward token not yet claimed by users 45 | /// @return totalSecondsClaimedX128 Total liquidity-seconds claimed, represented as a UQ32.128 46 | /// @return numberOfStakes The count of deposits that are currently staked for the incentive 47 | function incentives(bytes32 incentiveId) 48 | external 49 | view 50 | returns ( 51 | uint256 totalRewardUnclaimed, 52 | uint160 totalSecondsClaimedX128, 53 | uint96 numberOfStakes 54 | ); 55 | 56 | /// @notice Returns information about a deposited NFT 57 | /// @return owner The owner of the deposited NFT 58 | /// @return numberOfStakes Counter of how many incentives for which the liquidity is staked 59 | /// @return tickLower The lower tick of the range 60 | /// @return tickUpper The upper tick of the range 61 | function deposits(uint256 tokenId) 62 | external 63 | view 64 | returns ( 65 | address owner, 66 | uint48 numberOfStakes, 67 | int24 tickLower, 68 | int24 tickUpper 69 | ); 70 | 71 | /// @notice Returns information about a staked liquidity NFT 72 | /// @param tokenId The ID of the staked token 73 | /// @param incentiveId The ID of the incentive for which the token is staked 74 | /// @return secondsPerLiquidityInsideInitialX128 secondsPerLiquidity represented as a UQ32.128 75 | /// @return liquidity The amount of liquidity in the NFT as of the last time the rewards were computed 76 | function stakes(uint256 tokenId, bytes32 incentiveId) 77 | external 78 | view 79 | returns (uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity); 80 | 81 | /// @notice Returns amounts of reward tokens owed to a given address according to the last time all stakes were updated 82 | /// @param rewardToken The token for which to check rewards 83 | /// @param owner The owner for which the rewards owed are checked 84 | /// @return rewardsOwed The amount of the reward token claimable by the owner 85 | function rewards(IERC20Minimal rewardToken, address owner) external view returns (uint256 rewardsOwed); 86 | 87 | /// @notice Creates a new liquidity mining incentive program 88 | /// @param key Details of the incentive to create 89 | /// @param reward The amount of reward tokens to be distributed 90 | function createIncentive(IncentiveKey memory key, uint256 reward) external; 91 | 92 | /// @notice Ends an incentive after the incentive end time has passed and all stakes have been withdrawn 93 | /// @param key Details of the incentive to end 94 | /// @return refund The remaining reward tokens when the incentive is ended 95 | function endIncentive(IncentiveKey memory key) external returns (uint256 refund); 96 | 97 | /// @notice Transfers ownership of a deposit from the sender to the given recipient 98 | /// @param tokenId The ID of the token (and the deposit) to transfer 99 | /// @param to The new owner of the deposit 100 | function transferDeposit(uint256 tokenId, address to) external; 101 | 102 | /// @notice Withdraws a Uniswap V3 LP token `tokenId` from this contract to the recipient `to` 103 | /// @param tokenId The unique identifier of an Uniswap V3 LP token 104 | /// @param to The address where the LP token will be sent 105 | /// @param data An optional data array that will be passed along to the `to` address via the NFT safeTransferFrom 106 | function withdrawToken( 107 | uint256 tokenId, 108 | address to, 109 | bytes memory data 110 | ) external; 111 | 112 | /// @notice Stakes a Uniswap V3 LP token 113 | /// @param key The key of the incentive for which to stake the NFT 114 | /// @param tokenId The ID of the token to stake 115 | function stakeToken(IncentiveKey memory key, uint256 tokenId) external; 116 | 117 | /// @notice Unstakes a Uniswap V3 LP token 118 | /// @param key The key of the incentive for which to unstake the NFT 119 | /// @param tokenId The ID of the token to unstake 120 | function unstakeToken(IncentiveKey memory key, uint256 tokenId) external; 121 | 122 | /// @notice Transfers `amountRequested` of accrued `rewardToken` rewards from the contract to the recipient `to` 123 | /// @param rewardToken The token being distributed as a reward 124 | /// @param to The address where claimed rewards will be sent to 125 | /// @param amountRequested The amount of reward tokens to claim. Claims entire reward amount if set to 0. 126 | /// @return reward The amount of reward tokens claimed 127 | function claimReward( 128 | IERC20Minimal rewardToken, 129 | address to, 130 | uint256 amountRequested 131 | ) external returns (uint256 reward); 132 | 133 | /// @notice Calculates the reward amount that will be received for the given stake 134 | /// @param key The key of the incentive 135 | /// @param tokenId The ID of the token 136 | /// @return reward The reward accrued to the NFT for the given incentive thus far 137 | function getRewardInfo(IncentiveKey memory key, uint256 tokenId) 138 | external 139 | returns (uint256 reward, uint160 secondsInsideX128); 140 | 141 | /// @notice Event emitted when a liquidity mining incentive has been created 142 | /// @param rewardToken The token being distributed as a reward 143 | /// @param pool The Uniswap V3 pool 144 | /// @param startTime The time when the incentive program begins 145 | /// @param endTime The time when rewards stop accruing 146 | /// @param refundee The address which receives any remaining reward tokens after the end time 147 | /// @param reward The amount of reward tokens to be distributed 148 | event IncentiveCreated( 149 | IERC20Minimal indexed rewardToken, 150 | IUniswapV3Pool indexed pool, 151 | uint256 startTime, 152 | uint256 endTime, 153 | address refundee, 154 | uint256 reward 155 | ); 156 | 157 | /// @notice Event that can be emitted when a liquidity mining incentive has ended 158 | /// @param incentiveId The incentive which is ending 159 | /// @param refund The amount of reward tokens refunded 160 | event IncentiveEnded(bytes32 indexed incentiveId, uint256 refund); 161 | 162 | /// @notice Emitted when ownership of a deposit changes 163 | /// @param tokenId The ID of the deposit (and token) that is being transferred 164 | /// @param oldOwner The owner before the deposit was transferred 165 | /// @param newOwner The owner after the deposit was transferred 166 | event DepositTransferred(uint256 indexed tokenId, address indexed oldOwner, address indexed newOwner); 167 | 168 | /// @notice Event emitted when a Uniswap V3 LP token has been staked 169 | /// @param tokenId The unique identifier of an Uniswap V3 LP token 170 | /// @param liquidity The amount of liquidity staked 171 | /// @param incentiveId The incentive in which the token is staking 172 | event TokenStaked(uint256 indexed tokenId, bytes32 indexed incentiveId, uint128 liquidity); 173 | 174 | /// @notice Event emitted when a Uniswap V3 LP token has been unstaked 175 | /// @param tokenId The unique identifier of an Uniswap V3 LP token 176 | /// @param incentiveId The incentive in which the token is staking 177 | event TokenUnstaked(uint256 indexed tokenId, bytes32 indexed incentiveId); 178 | 179 | /// @notice Event emitted when a reward token has been claimed 180 | /// @param to The address where claimed rewards were sent to 181 | /// @param reward The amount of reward tokens claimed 182 | event RewardClaimed(address indexed to, uint256 reward); 183 | } 184 | -------------------------------------------------------------------------------- /contracts/libraries/IncentiveId.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | pragma abicoder v2; 4 | 5 | import '../interfaces/IUniswapV3Staker.sol'; 6 | 7 | library IncentiveId { 8 | /// @notice Calculate the key for a staking incentive 9 | /// @param key The components used to compute the incentive identifier 10 | /// @return incentiveId The identifier for the incentive 11 | function compute(IUniswapV3Staker.IncentiveKey memory key) internal pure returns (bytes32 incentiveId) { 12 | return keccak256(abi.encode(key)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /contracts/libraries/NFTPositionInfo.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | 4 | import '@uniswap/v3-periphery/contracts/interfaces/INonfungiblePositionManager.sol'; 5 | import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol'; 6 | import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol'; 7 | 8 | import '@uniswap/v3-periphery/contracts/libraries/PoolAddress.sol'; 9 | 10 | /// @notice Encapsulates the logic for getting info about a NFT token ID 11 | library NFTPositionInfo { 12 | /// @param factory The address of the Uniswap V3 Factory used in computing the pool address 13 | /// @param nonfungiblePositionManager The address of the nonfungible position manager to query 14 | /// @param tokenId The unique identifier of an Uniswap V3 LP token 15 | /// @return pool The address of the Uniswap V3 pool 16 | /// @return tickLower The lower tick of the Uniswap V3 position 17 | /// @return tickUpper The upper tick of the Uniswap V3 position 18 | /// @return liquidity The amount of liquidity staked 19 | function getPositionInfo( 20 | IUniswapV3Factory factory, 21 | INonfungiblePositionManager nonfungiblePositionManager, 22 | uint256 tokenId 23 | ) 24 | internal 25 | view 26 | returns ( 27 | IUniswapV3Pool pool, 28 | int24 tickLower, 29 | int24 tickUpper, 30 | uint128 liquidity 31 | ) 32 | { 33 | address token0; 34 | address token1; 35 | uint24 fee; 36 | (, , token0, token1, fee, tickLower, tickUpper, liquidity, , , , ) = nonfungiblePositionManager.positions( 37 | tokenId 38 | ); 39 | 40 | pool = IUniswapV3Pool( 41 | PoolAddress.computeAddress( 42 | address(factory), 43 | PoolAddress.PoolKey({token0: token0, token1: token1, fee: fee}) 44 | ) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /contracts/libraries/RewardMath.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | 4 | import '@uniswap/v3-core/contracts/libraries/FullMath.sol'; 5 | import '@openzeppelin/contracts/math/Math.sol'; 6 | 7 | /// @title Math for computing rewards 8 | /// @notice Allows computing rewards given some parameters of stakes and incentives 9 | library RewardMath { 10 | /// @notice Compute the amount of rewards owed given parameters of the incentive and stake 11 | /// @param totalRewardUnclaimed The total amount of unclaimed rewards left for an incentive 12 | /// @param totalSecondsClaimedX128 How many full liquidity-seconds have been already claimed for the incentive 13 | /// @param startTime When the incentive rewards began in epoch seconds 14 | /// @param endTime When rewards are no longer being dripped out in epoch seconds 15 | /// @param liquidity The amount of liquidity, assumed to be constant over the period over which the snapshots are measured 16 | /// @param secondsPerLiquidityInsideInitialX128 The seconds per liquidity of the liquidity tick range as of the beginning of the period 17 | /// @param secondsPerLiquidityInsideX128 The seconds per liquidity of the liquidity tick range as of the current block timestamp 18 | /// @param currentTime The current block timestamp, which must be greater than or equal to the start time 19 | /// @return reward The amount of rewards owed 20 | /// @return secondsInsideX128 The total liquidity seconds inside the position's range for the duration of the stake 21 | function computeRewardAmount( 22 | uint256 totalRewardUnclaimed, 23 | uint160 totalSecondsClaimedX128, 24 | uint256 startTime, 25 | uint256 endTime, 26 | uint128 liquidity, 27 | uint160 secondsPerLiquidityInsideInitialX128, 28 | uint160 secondsPerLiquidityInsideX128, 29 | uint256 currentTime 30 | ) internal pure returns (uint256 reward, uint160 secondsInsideX128) { 31 | // this should never be called before the start time 32 | assert(currentTime >= startTime); 33 | 34 | // this operation is safe, as the difference cannot be greater than 1/stake.liquidity 35 | secondsInsideX128 = (secondsPerLiquidityInsideX128 - secondsPerLiquidityInsideInitialX128) * liquidity; 36 | 37 | uint256 totalSecondsUnclaimedX128 = 38 | ((Math.max(endTime, currentTime) - startTime) << 128) - totalSecondsClaimedX128; 39 | 40 | reward = FullMath.mulDiv(totalRewardUnclaimed, secondsInsideX128, totalSecondsUnclaimedX128); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /contracts/libraries/TransferHelperExtended.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity >=0.6.0; 3 | 4 | import '@uniswap/v3-periphery/contracts/libraries/TransferHelper.sol'; 5 | import '@openzeppelin/contracts/utils/Address.sol'; 6 | 7 | library TransferHelperExtended { 8 | using Address for address; 9 | 10 | /// @notice Transfers tokens from the targeted address to the given destination 11 | /// @notice Errors with 'STF' if transfer fails 12 | /// @param token The contract address of the token to be transferred 13 | /// @param from The originating address from which the tokens will be transferred 14 | /// @param to The destination address of the transfer 15 | /// @param value The amount to be transferred 16 | function safeTransferFrom( 17 | address token, 18 | address from, 19 | address to, 20 | uint256 value 21 | ) internal { 22 | require(token.isContract(), 'TransferHelperExtended::safeTransferFrom: call to non-contract'); 23 | TransferHelper.safeTransferFrom(token, from, to, value); 24 | } 25 | 26 | /// @notice Transfers tokens from msg.sender to a recipient 27 | /// @dev Errors with ST if transfer fails 28 | /// @param token The contract address of the token which will be transferred 29 | /// @param to The recipient of the transfer 30 | /// @param value The value of the transfer 31 | function safeTransfer( 32 | address token, 33 | address to, 34 | uint256 value 35 | ) internal { 36 | require(token.isContract(), 'TransferHelperExtended::safeTransfer: call to non-contract'); 37 | TransferHelper.safeTransfer(token, to, value); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /contracts/test/TestERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | // uniswap-v3-core/contracts/test/TestERC20.sol 3 | pragma solidity =0.7.6; 4 | 5 | import '@uniswap/v3-core/contracts/interfaces/IERC20Minimal.sol'; 6 | 7 | contract TestERC20 is IERC20Minimal { 8 | mapping(address => uint256) public override balanceOf; 9 | mapping(address => mapping(address => uint256)) public override allowance; 10 | 11 | constructor(uint256 amountToMint) { 12 | mint(msg.sender, amountToMint); 13 | } 14 | 15 | function mint(address to, uint256 amount) public { 16 | uint256 balanceNext = balanceOf[to] + amount; 17 | require(balanceNext >= amount, 'overflow balance'); 18 | balanceOf[to] = balanceNext; 19 | } 20 | 21 | function transfer(address recipient, uint256 amount) external override returns (bool) { 22 | uint256 balanceBefore = balanceOf[msg.sender]; 23 | require(balanceBefore >= amount, 'insufficient balance'); 24 | balanceOf[msg.sender] = balanceBefore - amount; 25 | 26 | uint256 balanceRecipient = balanceOf[recipient]; 27 | require(balanceRecipient + amount >= balanceRecipient, 'recipient balance overflow'); 28 | balanceOf[recipient] = balanceRecipient + amount; 29 | 30 | emit Transfer(msg.sender, recipient, amount); 31 | return true; 32 | } 33 | 34 | function approve(address spender, uint256 amount) external override returns (bool) { 35 | allowance[msg.sender][spender] = amount; 36 | emit Approval(msg.sender, spender, amount); 37 | return true; 38 | } 39 | 40 | function transferFrom( 41 | address sender, 42 | address recipient, 43 | uint256 amount 44 | ) external override returns (bool) { 45 | uint256 allowanceBefore = allowance[sender][msg.sender]; 46 | require(allowanceBefore >= amount, 'allowance insufficient'); 47 | 48 | allowance[sender][msg.sender] = allowanceBefore - amount; 49 | 50 | uint256 balanceRecipient = balanceOf[recipient]; 51 | require(balanceRecipient + amount >= balanceRecipient, 'overflow balance recipient'); 52 | balanceOf[recipient] = balanceRecipient + amount; 53 | uint256 balanceSender = balanceOf[sender]; 54 | require(balanceSender >= amount, 'underflow balance sender'); 55 | balanceOf[sender] = balanceSender - amount; 56 | 57 | emit Transfer(sender, recipient, amount); 58 | return true; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /contracts/test/TestIncentiveId.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | pragma abicoder v2; 4 | 5 | import '../interfaces/IUniswapV3Staker.sol'; 6 | 7 | import '../libraries/IncentiveId.sol'; 8 | 9 | /// @dev Test contract for IncentiveId 10 | contract TestIncentiveId { 11 | function compute(IUniswapV3Staker.IncentiveKey memory key) public pure returns (bytes32) { 12 | return IncentiveId.compute(key); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /contracts/test/TestRewardMath.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity =0.7.6; 3 | pragma abicoder v2; 4 | 5 | import '../interfaces/IUniswapV3Staker.sol'; 6 | 7 | import '../libraries/RewardMath.sol'; 8 | 9 | /// @dev Test contract for RewardMatrh 10 | contract TestRewardMath { 11 | function computeRewardAmount( 12 | uint256 totalRewardUnclaimed, 13 | uint160 totalSecondsClaimedX128, 14 | uint256 startTime, 15 | uint256 endTime, 16 | uint128 liquidity, 17 | uint160 secondsPerLiquidityInsideInitialX128, 18 | uint160 secondsPerLiquidityInsideX128, 19 | uint256 currentTime 20 | ) public pure returns (uint256 reward, uint160 secondsInsideX128) { 21 | (reward, secondsInsideX128) = RewardMath.computeRewardAmount( 22 | totalRewardUnclaimed, 23 | totalSecondsClaimedX128, 24 | startTime, 25 | endTime, 26 | liquidity, 27 | secondsPerLiquidityInsideInitialX128, 28 | secondsPerLiquidityInsideX128, 29 | currentTime 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docs/Design.md: -------------------------------------------------------------------------------- 1 | # Uniswap V3 Staker 2 | 3 | There is a canonical position staking contract, Staker. 4 | 5 | ## Data Structures 6 | 7 | ```solidity 8 | struct Incentive { 9 | uint128 totalRewardUnclaimed; 10 | uint128 numberOfStakes; 11 | uint160 totalSecondsClaimedX128; 12 | } 13 | 14 | struct Deposit { 15 | address owner; 16 | uint96 numberOfStakes; 17 | } 18 | 19 | struct Stake { 20 | uint160 secondsPerLiquidityInsideInitialX128; 21 | uint128 liquidity; 22 | } 23 | ``` 24 | 25 | State: 26 | 27 | ```solidity 28 | IUniswapV3Factory public immutable factory; 29 | INonfungiblePositionManager public immutable nonfungiblePositionManager; 30 | 31 | /// @dev bytes32 refers to the return value of IncentiveId.compute 32 | mapping(bytes32 => Incentive) public incentives; 33 | 34 | /// @dev deposits[tokenId] => Deposit 35 | mapping(uint256 => Deposit) public deposits; 36 | 37 | /// @dev stakes[tokenId][incentiveHash] => Stake 38 | mapping(uint256 => mapping(bytes32 => Stake)) public stakes; 39 | 40 | /// @dev rewards[rewardToken][msg.sender] => uint256 41 | mapping(address => mapping(address => uint256)) public rewards; 42 | ``` 43 | 44 | Params: 45 | 46 | ```solidity 47 | struct CreateIncentiveParams { 48 | address rewardToken; 49 | address pool; 50 | uint256 startTime; 51 | uint256 endTime; 52 | uint128 totalReward; 53 | } 54 | 55 | struct EndIncentiveParams { 56 | address creator; 57 | address rewardToken; 58 | address pool; 59 | uint256 startTime; 60 | uint256 endTime; 61 | } 62 | 63 | ``` 64 | 65 | ## Incentives 66 | 67 | ### `createIncentive(CreateIncentiveParams memory params)` 68 | 69 | `createIncentive` creates a liquidity mining incentive program. The key used to look up an Incentive is the hash of its immutable properties. 70 | 71 | **Check:** 72 | 73 | - Incentive with these params does not already exist 74 | - Timestamps: `params.endTime >= params.startTime`, `params.startTime >= block.timestamp` 75 | - Incentive with this ID does not already exist. 76 | 77 | **Effects:** 78 | 79 | - Sets `incentives[key] = Incentive(totalRewardUnclaimed=totalReward, totalSecondsClaimedX128=0, rewardToken=rewardToken)` 80 | 81 | **Interaction:** 82 | 83 | - Transfers `params.totalReward` from `msg.sender` to self. 84 | - Make sure there is a check here and it fails if the transfer fails 85 | - Emits `IncentiveCreated` 86 | 87 | ### `endIncentive(EndIncentiveParams memory params)` 88 | 89 | `endIncentive` can be called by anyone to end an Incentive after the `endTime` has passed, transferring `totalRewardUnclaimed` of `rewardToken` back to `refundee`. 90 | 91 | **Check:** 92 | 93 | - `block.timestamp > params.endTime` 94 | - Incentive exists (`incentive.totalRewardUnclaimed != 0`) 95 | 96 | **Effects:** 97 | 98 | - deletes `incentives[key]` (This is a new change) 99 | 100 | **Interactions:** 101 | 102 | - safeTransfers `totalRewardUnclaimed` of `rewardToken` to the incentive creator `msg.sender` 103 | - emits `IncentiveEnded` 104 | 105 | ## Deposit/Withdraw Token 106 | 107 | **Interactions** 108 | 109 | - `nonfungiblePositionManager.safeTransferFrom(sender, this, tokenId)` 110 | - This transfer triggers the onERC721Received hook 111 | 112 | ### `onERC721Received(address, address from, uint256 tokenId, bytes calldata data)` 113 | 114 | **Check:** 115 | 116 | - Make sure sender is univ3 nft 117 | 118 | **Effects:** 119 | 120 | - Creates a deposit for the token setting deposit `owner` to `from`. 121 | - Setting `owner` to `from` ensures that the owner of the token also owns the deposit. Approved addresses and operators may first transfer the token to themselves before depositing for deposit ownership. 122 | - If `data.length>0`, stakes the token in one or more incentives 123 | 124 | ### `withdrawToken(uint256 tokenId, address to, bytes memory data)` 125 | 126 | **Checks** 127 | 128 | - Check that a Deposit exists for the token and that `msg.sender` is the `owner` on that Deposit. 129 | - Check that `numberOfStakes` on that Deposit is 0. 130 | 131 | **Effects** 132 | 133 | - Delete the Deposit `delete deposits[tokenId]`. 134 | 135 | **Interactions** 136 | 137 | - `safeTransferFrom` the token to `to` with `data`. 138 | - emit `DepositTransferred(token, deposit.owner, address(0))` 139 | 140 | ## Stake/Unstake/Rewards 141 | 142 | ### `stakeToken` 143 | 144 | **Check:** 145 | 146 | - `deposits[params.tokenId].owner == msg.sender` 147 | - Make sure incentive actually exists and has reward that could be claimed (incentive.rewardToken != address(0)) 148 | - Check if this check can check totalRewardUnclaimed instead 149 | - Make sure token not already staked 150 | 151 | ### `claimReward` 152 | 153 | **Interactions** 154 | 155 | - `msg.sender` to withdraw all of their reward balance in a specific token to a specified `to` address. 156 | 157 | - emit RewardClaimed(to, reward) 158 | 159 | ### `unstakeToken` 160 | 161 | To unstake an NFT, you call `unstakeToken`, which takes all the same arguments as `stake`. 162 | 163 | **Checks** 164 | 165 | - It checks that you are the owner of the Deposit 166 | - It checks that there exists a `Stake` for the provided key (with exists=true). 167 | 168 | **Effects** 169 | 170 | - Deletes the Stake. 171 | - Decrements `numberOfStakes` for the Deposit by 1. 172 | - `totalRewardsUnclaimed` is decremented by `reward`. 173 | - `totalSecondsClaimed` is incremented by `seconds`. 174 | - Increments `rewards[rewardToken][msg.sender]` by the amount given by `getRewardInfo`. 175 | 176 | ### `getRewardInfo` 177 | 178 | - It computes `secondsInsideX128` (the total liquidity seconds for which rewards are owed) for a given Stake, by: 179 | - using`snapshotCumulativesInside` from the Uniswap v3 core contract to get `secondsPerLiquidityInRangeX128`, and subtracting `secondsPerLiquidityInRangeInitialX128`. 180 | - Multiplying that by `stake.liquidity` to get the total seconds accrued by the liquidity in that period 181 | - Note that X128 means it's a `UQ32X128`. 182 | 183 | - It computes `totalSecondsUnclaimed` by taking `max(endTime, block.timestamp) - startTime`, casting it as a Q128, and subtracting `totalSecondsClaimedX128`. 184 | 185 | - It computes `rewardRate` for the Incentive casting `incentive.totalRewardUnclaimed` as a Q128, then dividing it by `totalSecondsUnclaimedX128`. 186 | 187 | - `reward` is then calculated as `secondsInsideX128` times the `rewardRate`, scaled down to a regular uint128. 188 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers' 2 | import '@nomiclabs/hardhat-etherscan' 3 | import '@nomiclabs/hardhat-waffle' 4 | import '@typechain/hardhat' 5 | import 'hardhat-contract-sizer' 6 | import { HardhatUserConfig } from 'hardhat/config' 7 | import { SolcUserConfig } from 'hardhat/types' 8 | import 'solidity-coverage' 9 | 10 | const DEFAULT_COMPILER_SETTINGS: SolcUserConfig = { 11 | version: '0.7.6', 12 | settings: { 13 | optimizer: { 14 | enabled: true, 15 | runs: 1_000_000, 16 | }, 17 | metadata: { 18 | bytecodeHash: 'none', 19 | }, 20 | }, 21 | } 22 | 23 | if (process.env.RUN_COVERAGE == '1') { 24 | /** 25 | * Updates the default compiler settings when running coverage. 26 | * 27 | * See https://github.com/sc-forks/solidity-coverage/issues/417#issuecomment-730526466 28 | */ 29 | console.info('Using coverage compiler settings') 30 | DEFAULT_COMPILER_SETTINGS.settings.details = { 31 | yul: true, 32 | yulDetails: { 33 | stackAllocation: true, 34 | }, 35 | } 36 | } 37 | 38 | const config: HardhatUserConfig = { 39 | networks: { 40 | hardhat: { 41 | allowUnlimitedContractSize: false, 42 | }, 43 | mainnet: { 44 | url: `https://mainnet.infura.io/v3/${process.env.INFURA_API_KEY}`, 45 | }, 46 | ropsten: { 47 | url: `https://ropsten.infura.io/v3/${process.env.INFURA_API_KEY}`, 48 | }, 49 | rinkeby: { 50 | url: `https://rinkeby.infura.io/v3/${process.env.INFURA_API_KEY}`, 51 | }, 52 | goerli: { 53 | url: `https://goerli.infura.io/v3/${process.env.INFURA_API_KEY}`, 54 | }, 55 | kovan: { 56 | url: `https://kovan.infura.io/v3/${process.env.INFURA_API_KEY}`, 57 | }, 58 | arbitrumRinkeby: { 59 | url: `https://arbitrum-rinkeby.infura.io/v3/${process.env.INFURA_API_KEY}`, 60 | }, 61 | arbitrum: { 62 | url: `https://arbitrum-mainnet.infura.io/v3/${process.env.INFURA_API_KEY}`, 63 | }, 64 | optimismKovan: { 65 | url: `https://optimism-kovan.infura.io/v3/${process.env.INFURA_API_KEY}`, 66 | }, 67 | optimism: { 68 | url: `https://optimism-mainnet.infura.io/v3/${process.env.INFURA_API_KEY}`, 69 | }, 70 | mumbai: { 71 | url: `https://polygon-mumbai.infura.io/v3/${process.env.INFURA_API_KEY}`, 72 | }, 73 | polygon: { 74 | url: `https://polygon-mainnet.infura.io/v3/${process.env.INFURA_API_KEY}`, 75 | }, 76 | }, 77 | solidity: { 78 | compilers: [DEFAULT_COMPILER_SETTINGS], 79 | }, 80 | contractSizer: { 81 | alphaSort: false, 82 | disambiguatePaths: true, 83 | runOnCompile: false, 84 | }, 85 | } 86 | 87 | if (process.env.ETHERSCAN_API_KEY) { 88 | config.etherscan = { 89 | // Your API key for Etherscan 90 | // Obtain one at https://etherscan.io/ 91 | apiKey: process.env.ETHERSCAN_API_KEY, 92 | } 93 | } 94 | 95 | export default config 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uniswap/v3-staker", 3 | "description": "Canonical liquidity mining contract for Uniswap V3", 4 | "license": "GPL-3.0-or-later", 5 | "version": "1.0.2", 6 | "homepage": "https://uniswap.org", 7 | "keywords": [ 8 | "uniswap", 9 | "liquidity mining", 10 | "v3" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/Uniswap/uniswap-v3-staker" 15 | }, 16 | "files": [ 17 | "contracts", 18 | "!contracts/test", 19 | "artifacts/contracts/**/*.json", 20 | "!artifacts/contracts/**/*.dbg.json", 21 | "!artifacts/contracts/test/**/*", 22 | "!artifacts/contracts/base/**/*" 23 | ], 24 | "engines": { 25 | "node": ">=10" 26 | }, 27 | "dependencies": { 28 | "@openzeppelin/contracts": "3.4.1-solc-0.7-2", 29 | "@uniswap/v3-core": "1.0.0", 30 | "@uniswap/v3-periphery": "^1.0.1" 31 | }, 32 | "devDependencies": { 33 | "@nomiclabs/hardhat-ethers": "^2.0.2", 34 | "@nomiclabs/hardhat-etherscan": "^2.1.8", 35 | "@nomiclabs/hardhat-waffle": "^2.0.1", 36 | "@typechain/ethers-v5": "^7.0.0", 37 | "@typechain/hardhat": "^2.0.1", 38 | "@types/chai": "^4.2.6", 39 | "@types/console-log-level": "^1.4.0", 40 | "@types/lodash": "^4.14.170", 41 | "@types/mocha": "^5.2.7", 42 | "@typescript-eslint/eslint-plugin": "^4.26.0", 43 | "@typescript-eslint/parser": "^4.26.0", 44 | "chai": "^4.2.0", 45 | "console-log-level": "^1.4.1", 46 | "eslint": "^7.28.0", 47 | "eslint-config-prettier": "^8.3.0", 48 | "eslint-plugin-unused-imports": "^1.1.1", 49 | "ethereum-waffle": "^3.0.2", 50 | "ethers": "^5.0.8", 51 | "hardhat": "^2.2.0", 52 | "hardhat-contract-sizer": "^2.0.3", 53 | "lodash": "^4.17.21", 54 | "mocha-chai-jest-snapshot": "^1.1.0", 55 | "prettier": "^2.2.1", 56 | "prettier-check": "^2.0.0", 57 | "prettier-plugin-solidity": "^1.0.0-beta.10", 58 | "solhint": "^3.2.1", 59 | "solhint-plugin-prettier": "^0.0.5", 60 | "solidity-coverage": "^0.7.16", 61 | "ts-node": "^8.5.4", 62 | "typechain": "^5.0.0", 63 | "typescript": "^4.3.2" 64 | }, 65 | "scripts": { 66 | "compile": "hardhat compile", 67 | "lint": "eslint . --ext .ts", 68 | "prettier:check": "prettier-check contracts/**/*.sol test/**/*.ts types/*.ts", 69 | "size-contracts": "hardhat compile && hardhat size-contracts", 70 | "test": "hardhat test", 71 | "clear-cache": "rm -rf artifacts/ cache/ typechain/", 72 | "coverage": "RUN_COVERAGE=1 hardhat coverage" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/UniswapV3Staker.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { constants } from 'ethers' 2 | import { TestContext, LoadFixtureFunction } from './types' 3 | import { TestERC20 } from '../typechain' 4 | import { 5 | BigNumber, 6 | blockTimestamp, 7 | BN, 8 | BNe18, 9 | expect, 10 | FeeAmount, 11 | getMaxTick, 12 | getMinTick, 13 | TICK_SPACINGS, 14 | uniswapFixture, 15 | log, 16 | days, 17 | ratioE18, 18 | bnSum, 19 | getCurrentTick, 20 | BNe, 21 | mintPosition, 22 | } from './shared' 23 | import { createTimeMachine } from './shared/time' 24 | import { ERC20Helper, HelperCommands, incentiveResultToStakeAdapter } from './helpers' 25 | import { createFixtureLoader, provider } from './shared/provider' 26 | import { ActorFixture } from './shared/actors' 27 | import { Fixture } from 'ethereum-waffle' 28 | import { HelperTypes } from './helpers/types' 29 | import { Wallet } from '@ethersproject/wallet' 30 | 31 | let loadFixture: LoadFixtureFunction 32 | 33 | describe('integration', async () => { 34 | const wallets = provider.getWallets() 35 | const Time = createTimeMachine(provider) 36 | const actors = new ActorFixture(wallets, provider) 37 | const e20h = new ERC20Helper() 38 | 39 | before('create fixture loader', async () => { 40 | loadFixture = createFixtureLoader(wallets, provider) 41 | }) 42 | 43 | describe('there are three LPs in the same range', async () => { 44 | type TestSubject = { 45 | stakes: Array 46 | createIncentiveResult: HelperTypes.CreateIncentive.Result 47 | helpers: HelperCommands 48 | context: TestContext 49 | } 50 | let subject: TestSubject 51 | 52 | const totalReward = BNe18(3_000) 53 | const duration = days(30) 54 | const ticksToStake: [number, number] = [ 55 | getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 56 | getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 57 | ] 58 | const amountsToStake: [BigNumber, BigNumber] = [BNe18(1_000), BNe18(1_000)] 59 | 60 | const scenario: Fixture = async (_wallets, _provider) => { 61 | const context = await uniswapFixture(_wallets, _provider) 62 | const epoch = await blockTimestamp() 63 | 64 | const { 65 | tokens: [token0, token1, rewardToken], 66 | } = context 67 | const helpers = HelperCommands.fromTestContext(context, actors, provider) 68 | 69 | const tokensToStake: [TestERC20, TestERC20] = [token0, token1] 70 | 71 | const startTime = epoch + 1_000 72 | const endTime = startTime + duration 73 | 74 | const createIncentiveResult = await helpers.createIncentiveFlow({ 75 | startTime, 76 | endTime, 77 | rewardToken, 78 | poolAddress: context.pool01, 79 | totalReward, 80 | }) 81 | 82 | const params = { 83 | tokensToStake, 84 | amountsToStake, 85 | createIncentiveResult, 86 | ticks: ticksToStake, 87 | } 88 | 89 | await Time.set(startTime + 1) 90 | 91 | const stakes = await Promise.all( 92 | actors.lpUsers().map((lp) => 93 | helpers.mintDepositStakeFlow({ 94 | ...params, 95 | lp, 96 | }) 97 | ) 98 | ) 99 | 100 | return { 101 | context, 102 | stakes, 103 | helpers, 104 | createIncentiveResult, 105 | } 106 | } 107 | 108 | beforeEach('load fixture', async () => { 109 | subject = await loadFixture(scenario) 110 | }) 111 | 112 | describe('who all stake the entire time ', () => { 113 | it('allows them all to withdraw at the end', async () => { 114 | const { helpers, createIncentiveResult } = subject 115 | 116 | await Time.setAndMine(createIncentiveResult.endTime + 1) 117 | 118 | // Sanity check: make sure we go past the incentive end time. 119 | expect(await blockTimestamp(), 'test setup: must be run after start time').to.be.gte( 120 | createIncentiveResult.endTime 121 | ) 122 | 123 | // Everyone pulls their liquidity at the same time 124 | const unstakes = await Promise.all( 125 | subject.stakes.map(({ lp, tokenId }) => 126 | helpers.unstakeCollectBurnFlow({ 127 | lp, 128 | tokenId, 129 | createIncentiveResult, 130 | }) 131 | ) 132 | ) 133 | const rewardsEarned = bnSum(unstakes.map((o) => o.balance)) 134 | log.debug('Total rewards ', rewardsEarned.toString()) 135 | 136 | const { amountReturnedToCreator } = await helpers.endIncentiveFlow({ 137 | createIncentiveResult, 138 | }) 139 | expect(rewardsEarned.add(amountReturnedToCreator)).to.eq(totalReward) 140 | }) 141 | 142 | describe('time goes past the incentive end time', () => { 143 | it('still allows an LP to unstake if they have not already', async () => { 144 | const { 145 | createIncentiveResult, 146 | context: { nft, staker }, 147 | stakes, 148 | } = subject 149 | 150 | // Simple wrapper functions since we will call these several times 151 | const actions = { 152 | doUnstake: (params: HelperTypes.MintDepositStake.Result) => 153 | staker 154 | .connect(params.lp) 155 | .unstakeToken(incentiveResultToStakeAdapter(createIncentiveResult), params.tokenId), 156 | 157 | doWithdraw: (params: HelperTypes.MintDepositStake.Result) => 158 | staker.connect(params.lp).withdrawToken(params.tokenId, params.lp.address, '0x'), 159 | 160 | doClaimRewards: (params: HelperTypes.MintDepositStake.Result) => 161 | staker 162 | .connect(params.lp) 163 | .claimReward(createIncentiveResult.rewardToken.address, params.lp.address, BN('0')), 164 | } 165 | 166 | await Time.set(createIncentiveResult.endTime + 1) 167 | 168 | // First make sure it is still owned by the staker 169 | expect(await nft.ownerOf(stakes[0].tokenId)).to.eq(staker.address) 170 | 171 | // The incentive has not yet been ended by the creator 172 | const incentiveId = await subject.helpers.getIncentiveId(createIncentiveResult) 173 | 174 | // It allows the token to be unstaked the first time 175 | await expect(actions.doUnstake(stakes[0])) 176 | .to.emit(staker, 'TokenUnstaked') 177 | .withArgs(stakes[0].tokenId, incentiveId) 178 | 179 | // It does not allow them to claim rewards (since we're past end time) 180 | await actions.doClaimRewards(stakes[0]) 181 | 182 | // Owner is still the staker 183 | expect(await nft.ownerOf(stakes[0].tokenId)).to.eq(staker.address) 184 | 185 | // Now withdraw it 186 | await expect(actions.doWithdraw(stakes[0])) 187 | .to.emit(staker, 'DepositTransferred') 188 | .withArgs(stakes[0].tokenId, stakes[0].lp.address, constants.AddressZero) 189 | 190 | // Owner is now the LP 191 | expect(await nft.ownerOf(stakes[0].tokenId)).to.eq(stakes[0].lp.address) 192 | }) 193 | 194 | it('does not allow the LP to claim rewards', async () => {}) 195 | }) 196 | }) 197 | 198 | describe('when one LP unstakes halfway through', () => { 199 | it('only gives them one sixth the total reward', async () => { 200 | const { helpers, createIncentiveResult, stakes } = subject 201 | const { startTime, endTime } = createIncentiveResult 202 | 203 | // Halfway through, lp0 decides they want out. Pauvre lp0. 204 | await Time.setAndMine(startTime + duration / 2) 205 | 206 | const [lpUser0] = actors.lpUsers() 207 | let unstakes: Array = [] 208 | 209 | unstakes.push( 210 | await helpers.unstakeCollectBurnFlow({ 211 | lp: lpUser0, 212 | tokenId: stakes[0].tokenId, 213 | createIncentiveResult: subject.createIncentiveResult, 214 | }) 215 | ) 216 | 217 | /* 218 | * totalReward is 3000e18 219 | * 220 | * This user contributed 1/3 of the total liquidity (amountsToStake = 1000e18) 221 | * for the first half of the duration, then unstaked. 222 | * 223 | * So that's (1/3)*(1/2)*3000e18 = ~50e18 224 | */ 225 | // Uniswap/uniswap-v3-staker#144 226 | expect(unstakes[0].balance).to.beWithin(BNe(1, 15), BN('499989197530864021534')) 227 | 228 | // Now the other two LPs hold off till the end and unstake 229 | await Time.setAndMine(endTime + 1) 230 | const otherUnstakes = await Promise.all( 231 | stakes.slice(1).map(({ lp, tokenId }) => 232 | helpers.unstakeCollectBurnFlow({ 233 | lp, 234 | tokenId, 235 | createIncentiveResult, 236 | }) 237 | ) 238 | ) 239 | unstakes.push(...otherUnstakes) 240 | 241 | // We don't need this call anymore because we're already setting that time above 242 | // await Time.set(createIncentiveResult.endTime + 1) 243 | const { amountReturnedToCreator } = await helpers.endIncentiveFlow({ 244 | createIncentiveResult, 245 | }) 246 | 247 | /* lpUser{1,2} should each have 5/12 of the total rewards. 248 | (1/3 * 1/2) from before lpUser0 withdrew 249 | (1/2 * 1/2) from after lpUser0. */ 250 | 251 | expect(ratioE18(unstakes[1].balance, unstakes[0].balance)).to.eq('2.50') 252 | expect(ratioE18(unstakes[2].balance, unstakes[1].balance)).to.eq('1.00') 253 | 254 | // All should add up to totalReward 255 | expect(bnSum(unstakes.map((u) => u.balance)).add(amountReturnedToCreator)).to.eq(totalReward) 256 | }) 257 | 258 | describe('and then restakes at the 3/4 mark', () => { 259 | it('rewards based on their staked time', async () => { 260 | const { 261 | helpers, 262 | createIncentiveResult, 263 | stakes, 264 | context: { 265 | tokens: [token0, token1], 266 | }, 267 | } = subject 268 | const { startTime, endTime } = createIncentiveResult 269 | 270 | // Halfway through, lp0 decides they want out. Pauvre lp0. 271 | const [lpUser0] = actors.lpUsers() 272 | 273 | // lpUser0 unstakes at the halfway mark 274 | await Time.set(startTime + duration / 2) 275 | 276 | await helpers.unstakeCollectBurnFlow({ 277 | lp: lpUser0, 278 | tokenId: stakes[0].tokenId, 279 | createIncentiveResult: subject.createIncentiveResult, 280 | }) 281 | 282 | // lpUser0 then restakes at the 3/4 mark 283 | await Time.set(startTime + (3 / 4) * duration) 284 | const tokensToStake: [TestERC20, TestERC20] = [token0, token1] 285 | 286 | await e20h.ensureBalancesAndApprovals( 287 | lpUser0, 288 | [token0, token1], 289 | amountsToStake[0], 290 | subject.context.router.address 291 | ) 292 | 293 | const restake = await helpers.mintDepositStakeFlow({ 294 | lp: lpUser0, 295 | createIncentiveResult, 296 | tokensToStake, 297 | amountsToStake, 298 | ticks: ticksToStake, 299 | }) 300 | 301 | await Time.set(endTime + 1) 302 | 303 | const { balance: lpUser0Balance } = await helpers.unstakeCollectBurnFlow({ 304 | lp: lpUser0, 305 | tokenId: restake.tokenId, 306 | createIncentiveResult, 307 | }) 308 | 309 | // Uniswap/uniswap-v3-staker#144 310 | expect(lpUser0Balance).to.beWithin(BNe(1, 12), BN('749985223767771705507')) 311 | }) 312 | }) 313 | }) 314 | 315 | describe('when another LP starts staking halfway through', () => { 316 | describe('and provides half the liquidity', () => { 317 | it('gives them a smaller share of the reward', async () => { 318 | const { helpers, createIncentiveResult, stakes, context } = subject 319 | const { startTime, endTime } = createIncentiveResult 320 | 321 | // Halfway through, lp3 decides they want in. Good for them. 322 | await Time.set(startTime + duration / 2) 323 | 324 | const lpUser3 = actors.traderUser2() 325 | const tokensToStake: [TestERC20, TestERC20] = [context.tokens[0], context.tokens[1]] 326 | 327 | const extraStake = await helpers.mintDepositStakeFlow({ 328 | tokensToStake, 329 | amountsToStake: amountsToStake.map((a) => a.div(2)) as [BigNumber, BigNumber], 330 | createIncentiveResult, 331 | ticks: ticksToStake, 332 | lp: lpUser3, 333 | }) 334 | 335 | // Now, go to the end and get rewards 336 | await Time.setAndMine(endTime + 1) 337 | 338 | const unstakes = await Promise.all( 339 | stakes.concat(extraStake).map(({ lp, tokenId }) => 340 | helpers.unstakeCollectBurnFlow({ 341 | lp, 342 | tokenId, 343 | createIncentiveResult, 344 | }) 345 | ) 346 | ) 347 | 348 | expect(ratioE18(unstakes[2].balance, unstakes[3].balance)).to.eq('4.34') 349 | 350 | // await Time.set(endTime + 1) 351 | const { amountReturnedToCreator } = await helpers.endIncentiveFlow({ 352 | createIncentiveResult, 353 | }) 354 | expect(bnSum(unstakes.map((u) => u.balance)).add(amountReturnedToCreator)).to.eq(totalReward) 355 | }) 356 | }) 357 | }) 358 | 359 | describe('when another LP adds liquidity but does not stake', () => { 360 | it('still changes the reward amounts', async () => { 361 | const { helpers, createIncentiveResult, context, stakes } = subject 362 | 363 | // Go halfway through 364 | await Time.set(createIncentiveResult.startTime + duration / 2) 365 | 366 | const lpUser3 = actors.traderUser2() 367 | 368 | // The non-staking user will deposit 25x the liquidity as the others 369 | const balanceDeposited = amountsToStake[0] 370 | 371 | // Someone starts staking 372 | await e20h.ensureBalancesAndApprovals( 373 | lpUser3, 374 | [context.token0, context.token1], 375 | balanceDeposited, 376 | context.nft.address 377 | ) 378 | 379 | await mintPosition(context.nft.connect(lpUser3), { 380 | token0: context.token0.address, 381 | token1: context.token1.address, 382 | fee: FeeAmount.MEDIUM, 383 | tickLower: ticksToStake[0], 384 | tickUpper: ticksToStake[1], 385 | recipient: lpUser3.address, 386 | amount0Desired: balanceDeposited, 387 | amount1Desired: balanceDeposited, 388 | amount0Min: 0, 389 | amount1Min: 0, 390 | deadline: (await blockTimestamp()) + 1000, 391 | }) 392 | 393 | await Time.set(createIncentiveResult.endTime + 1) 394 | 395 | const unstakes = await Promise.all( 396 | stakes.map(({ lp, tokenId }) => 397 | helpers.unstakeCollectBurnFlow({ 398 | lp, 399 | tokenId, 400 | createIncentiveResult, 401 | }) 402 | ) 403 | ) 404 | 405 | /** 406 | * The reward distributed to LPs should be: 407 | * 408 | * totalReward: is 3_000e18 409 | * 410 | * Incentive Start -> Halfway Through: 411 | * 3 LPs, all staking the same amount. Each LP gets roughly (totalReward/2) * (1/3) 412 | */ 413 | const firstHalfRewards = totalReward.div(BN('2')) 414 | 415 | /** 416 | * Halfway Through -> Incentive End: 417 | * 4 LPs, all providing the same liquidity. Only 3 LPs are staking, so they should 418 | * each get 1/4 the liquidity for that time. So That's 1/4 * 1/2 * 3_000e18 per staked LP. 419 | * */ 420 | const secondHalfRewards = totalReward.div(BN('2')).mul('3').div('4') 421 | const rewardsEarned = bnSum(unstakes.map((s) => s.balance)) 422 | expect(rewardsEarned).to.be.closeTo( 423 | // @ts-ignore 424 | firstHalfRewards.add(secondHalfRewards), 425 | BNe(5, 16) 426 | ) 427 | 428 | // await Time.set(createIncentiveResult.endTime + 1) 429 | const { amountReturnedToCreator } = await helpers.endIncentiveFlow({ 430 | createIncentiveResult, 431 | }) 432 | 433 | expect(amountReturnedToCreator.add(rewardsEarned)).to.eq(totalReward) 434 | }) 435 | }) 436 | }) 437 | 438 | describe('when there are different ranges staked', () => { 439 | type TestSubject = { 440 | createIncentiveResult: HelperTypes.CreateIncentive.Result 441 | helpers: HelperCommands 442 | context: TestContext 443 | } 444 | let subject: TestSubject 445 | 446 | const totalReward = BNe18(3_000) 447 | const duration = days(100) 448 | const baseAmount = BNe18(2) 449 | 450 | const scenario: Fixture = async (_wallets, _provider) => { 451 | const context = await uniswapFixture(_wallets, _provider) 452 | 453 | const helpers = HelperCommands.fromTestContext(context, new ActorFixture(_wallets, _provider), _provider) 454 | 455 | const epoch = await blockTimestamp() 456 | const startTime = epoch + 1_000 457 | const endTime = startTime + duration 458 | 459 | const createIncentiveResult = await helpers.createIncentiveFlow({ 460 | startTime, 461 | endTime, 462 | rewardToken: context.rewardToken, 463 | poolAddress: context.pool01, 464 | totalReward, 465 | }) 466 | 467 | return { 468 | context, 469 | helpers, 470 | createIncentiveResult, 471 | } 472 | } 473 | 474 | beforeEach('load fixture', async () => { 475 | subject = await loadFixture(scenario) 476 | }) 477 | 478 | it('rewards based on how long they are in range', async () => { 479 | const { helpers, context, createIncentiveResult } = subject 480 | type Position = { 481 | lp: Wallet 482 | amounts: [BigNumber, BigNumber] 483 | ticks: [number, number] 484 | } 485 | 486 | let midpoint = await getCurrentTick(context.poolObj.connect(actors.lpUser0())) 487 | 488 | const positions: Array = [ 489 | // lpUser0 stakes 2e18 from min-0 490 | { 491 | lp: actors.lpUser0(), 492 | amounts: [baseAmount, baseAmount], 493 | ticks: [getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), midpoint], 494 | }, 495 | // lpUser1 stakes 4e18 from 0-max 496 | { 497 | lp: actors.lpUser1(), 498 | amounts: [baseAmount.mul(2), baseAmount.mul(2)], 499 | ticks: [midpoint, getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM])], 500 | }, 501 | // lpUser2 stakes 8e18 from 0-max 502 | { 503 | lp: actors.lpUser2(), 504 | amounts: [baseAmount.mul(4), baseAmount.mul(4)], 505 | ticks: [midpoint, getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM])], 506 | }, 507 | ] 508 | 509 | const tokensToStake: [TestERC20, TestERC20] = [context.tokens[0], context.tokens[1]] 510 | 511 | Time.set(createIncentiveResult.startTime + 1) 512 | const stakes = await Promise.all( 513 | positions.map((p) => 514 | helpers.mintDepositStakeFlow({ 515 | lp: p.lp, 516 | tokensToStake, 517 | ticks: p.ticks, 518 | amountsToStake: p.amounts, 519 | createIncentiveResult, 520 | }) 521 | ) 522 | ) 523 | 524 | const trader = actors.traderUser0() 525 | 526 | await helpers.makeTickGoFlow({ 527 | trader, 528 | direction: 'up', 529 | desiredValue: midpoint + 10, 530 | }) 531 | 532 | // Go halfway through 533 | await Time.set(createIncentiveResult.startTime + duration / 2) 534 | 535 | await helpers.makeTickGoFlow({ 536 | trader, 537 | direction: 'down', 538 | desiredValue: midpoint - 10, 539 | }) 540 | 541 | await Time.set(createIncentiveResult.endTime + 1) 542 | 543 | /* lp0 provided all the liquidity for the second half of the duration. */ 544 | const { balance: lp0Balance } = await helpers.unstakeCollectBurnFlow({ 545 | lp: stakes[0].lp, 546 | tokenId: stakes[0].tokenId, 547 | createIncentiveResult, 548 | }) 549 | 550 | expect(lp0Balance).to.eq(BN('1499999131944544913825')) 551 | 552 | /* lp{1,2} provided liquidity for the first half of the duration. 553 | lp2 provided twice as much liquidity as lp1. */ 554 | const { balance: lp1Balance } = await helpers.unstakeCollectBurnFlow({ 555 | lp: stakes[1].lp, 556 | tokenId: stakes[1].tokenId, 557 | createIncentiveResult, 558 | }) 559 | 560 | const { balance: lp2Balance } = await helpers.unstakeCollectBurnFlow({ 561 | lp: stakes[2].lp, 562 | tokenId: stakes[2].tokenId, 563 | createIncentiveResult, 564 | }) 565 | 566 | expect(lp1Balance).to.eq(BN('499996238431987566881')) 567 | expect(lp2Balance).to.eq(BN('999990162082783775671')) 568 | 569 | await expect( 570 | helpers.unstakeCollectBurnFlow({ 571 | lp: stakes[2].lp, 572 | tokenId: stakes[2].tokenId, 573 | createIncentiveResult, 574 | }) 575 | ).to.be.reverted 576 | }) 577 | }) 578 | }) 579 | -------------------------------------------------------------------------------- /test/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, Wallet } from 'ethers' 2 | import { MockProvider } from 'ethereum-waffle' 3 | import { 4 | blockTimestamp, 5 | BNe18, 6 | FeeAmount, 7 | getCurrentTick, 8 | maxGas, 9 | MaxUint256, 10 | encodePath, 11 | arrayWrap, 12 | getMinTick, 13 | getMaxTick, 14 | BN, 15 | } from '../shared/index' 16 | import _ from 'lodash' 17 | import { 18 | TestERC20, 19 | INonfungiblePositionManager, 20 | UniswapV3Staker, 21 | IUniswapV3Pool, 22 | TestIncentiveId, 23 | } from '../../typechain' 24 | import { HelperTypes } from './types' 25 | import { ActorFixture } from '../shared/actors' 26 | import { mintPosition } from '../shared/fixtures' 27 | import { ISwapRouter } from '../../types/ISwapRouter' 28 | import { ethers } from 'hardhat' 29 | import { ContractParams } from '../../types/contractParams' 30 | import { TestContext } from '../types' 31 | 32 | /** 33 | * HelperCommands is a utility that abstracts away lower-level ethereum details 34 | * so that we can focus on core business logic. 35 | * 36 | * Each helper function should be a `HelperTypes.CommandFunction` 37 | */ 38 | export class HelperCommands { 39 | actors: ActorFixture 40 | provider: MockProvider 41 | staker: UniswapV3Staker 42 | nft: INonfungiblePositionManager 43 | router: ISwapRouter 44 | pool: IUniswapV3Pool 45 | testIncentiveId: TestIncentiveId 46 | 47 | DEFAULT_INCENTIVE_DURATION = 2_000 48 | DEFAULT_CLAIM_DURATION = 1_000 49 | DEFAULT_LP_AMOUNT = BNe18(10) 50 | DEFAULT_FEE_AMOUNT = FeeAmount.MEDIUM 51 | 52 | constructor({ 53 | provider, 54 | staker, 55 | nft, 56 | router, 57 | pool, 58 | actors, 59 | testIncentiveId, 60 | }: { 61 | provider: MockProvider 62 | staker: UniswapV3Staker 63 | nft: INonfungiblePositionManager 64 | router: ISwapRouter 65 | pool: IUniswapV3Pool 66 | actors: ActorFixture 67 | testIncentiveId: TestIncentiveId 68 | }) { 69 | this.actors = actors 70 | this.provider = provider 71 | this.staker = staker 72 | this.nft = nft 73 | this.router = router 74 | this.pool = pool 75 | this.testIncentiveId = testIncentiveId 76 | } 77 | 78 | static fromTestContext = (context: TestContext, actors: ActorFixture, provider: MockProvider): HelperCommands => { 79 | return new HelperCommands({ 80 | actors, 81 | provider, 82 | nft: context.nft, 83 | router: context.router, 84 | staker: context.staker, 85 | pool: context.poolObj, 86 | testIncentiveId: context.testIncentiveId, 87 | }) 88 | } 89 | 90 | /** 91 | * Creates a staking incentive owned by `incentiveCreator` for `totalReward` of `rewardToken` 92 | * 93 | * Side-Effects: 94 | * Transfers `rewardToken` to `incentiveCreator` if they do not have sufficient blaance. 95 | */ 96 | createIncentiveFlow: HelperTypes.CreateIncentive.Command = async (params) => { 97 | const { startTime } = params 98 | const endTime = params.endTime || startTime + this.DEFAULT_INCENTIVE_DURATION 99 | 100 | const incentiveCreator = this.actors.incentiveCreator() 101 | const times = { 102 | startTime, 103 | endTime, 104 | } 105 | const bal = await params.rewardToken.balanceOf(incentiveCreator.address) 106 | 107 | if (bal < params.totalReward) { 108 | await params.rewardToken.transfer(incentiveCreator.address, params.totalReward) 109 | } 110 | 111 | await params.rewardToken.connect(incentiveCreator).approve(this.staker.address, params.totalReward) 112 | 113 | await this.staker.connect(incentiveCreator).createIncentive( 114 | { 115 | pool: params.poolAddress, 116 | rewardToken: params.rewardToken.address, 117 | ...times, 118 | refundee: params.refundee || incentiveCreator.address, 119 | }, 120 | params.totalReward 121 | ) 122 | 123 | return { 124 | ..._.pick(params, ['poolAddress', 'totalReward', 'rewardToken']), 125 | ...times, 126 | refundee: params.refundee || incentiveCreator.address, 127 | } 128 | } 129 | 130 | /** 131 | * params.lp mints an NFT backed by a certain amount of `params.tokensToStake`. 132 | * 133 | * Side-Effects: 134 | * Funds `params.lp` with enough `params.tokensToStake` if they do not have enough. 135 | * Handles the ERC20 and ERC721 permits. 136 | */ 137 | mintDepositStakeFlow: HelperTypes.MintDepositStake.Command = async (params) => { 138 | // Make sure LP has enough balance 139 | const bal0 = await params.tokensToStake[0].balanceOf(params.lp.address) 140 | if (bal0 < params.amountsToStake[0]) 141 | await params.tokensToStake[0] 142 | // .connect(tokensOwner) 143 | .transfer(params.lp.address, params.amountsToStake[0]) 144 | 145 | const bal1 = await params.tokensToStake[1].balanceOf(params.lp.address) 146 | if (bal1 < params.amountsToStake[1]) 147 | await params.tokensToStake[1] 148 | // .connect(tokensOwner) 149 | .transfer(params.lp.address, params.amountsToStake[1]) 150 | 151 | // Make sure LP has authorized NFT to withdraw 152 | await params.tokensToStake[0].connect(params.lp).approve(this.nft.address, params.amountsToStake[0]) 153 | await params.tokensToStake[1].connect(params.lp).approve(this.nft.address, params.amountsToStake[1]) 154 | 155 | // The LP mints their NFT 156 | const tokenId = await mintPosition(this.nft.connect(params.lp), { 157 | token0: params.tokensToStake[0].address, 158 | token1: params.tokensToStake[1].address, 159 | fee: FeeAmount.MEDIUM, 160 | tickLower: params.ticks[0], 161 | tickUpper: params.ticks[1], 162 | recipient: params.lp.address, 163 | amount0Desired: params.amountsToStake[0], 164 | amount1Desired: params.amountsToStake[1], 165 | amount0Min: 0, 166 | amount1Min: 0, 167 | deadline: (await blockTimestamp()) + 1000, 168 | }) 169 | 170 | // Make sure LP has authorized staker 171 | await params.tokensToStake[0].connect(params.lp).approve(this.staker.address, params.amountsToStake[0]) 172 | await params.tokensToStake[1].connect(params.lp).approve(this.staker.address, params.amountsToStake[1]) 173 | 174 | // The LP approves and stakes their NFT 175 | await this.nft.connect(params.lp).approve(this.staker.address, tokenId) 176 | await this.nft 177 | .connect(params.lp) 178 | ['safeTransferFrom(address,address,uint256)'](params.lp.address, this.staker.address, tokenId) 179 | await this.staker 180 | .connect(params.lp) 181 | .stakeToken(incentiveResultToStakeAdapter(params.createIncentiveResult), tokenId) 182 | 183 | const stakedAt = await blockTimestamp() 184 | 185 | return { 186 | tokenId, 187 | stakedAt, 188 | lp: params.lp, 189 | } 190 | } 191 | 192 | depositFlow: HelperTypes.Deposit.Command = async (params) => { 193 | await this.nft.connect(params.lp).approve(this.staker.address, params.tokenId) 194 | 195 | await this.nft 196 | .connect(params.lp) 197 | ['safeTransferFrom(address,address,uint256)'](params.lp.address, this.staker.address, params.tokenId) 198 | } 199 | 200 | mintFlow: HelperTypes.Mint.Command = async (params) => { 201 | const fee = params.fee || FeeAmount.MEDIUM 202 | const e20h = new ERC20Helper() 203 | 204 | const amount0Desired = params.amounts ? params.amounts[0] : this.DEFAULT_LP_AMOUNT 205 | 206 | await e20h.ensureBalancesAndApprovals(params.lp, params.tokens[0], amount0Desired, this.nft.address) 207 | 208 | const amount1Desired = params.amounts ? params.amounts[1] : this.DEFAULT_LP_AMOUNT 209 | 210 | await e20h.ensureBalancesAndApprovals(params.lp, params.tokens[1], amount1Desired, this.nft.address) 211 | 212 | const tokenId = await mintPosition(this.nft.connect(params.lp), { 213 | token0: params.tokens[0].address, 214 | token1: params.tokens[1].address, 215 | fee, 216 | tickLower: params.tickLower || getMinTick(fee), 217 | tickUpper: params.tickUpper || getMaxTick(fee), 218 | recipient: params.lp.address, 219 | amount0Desired, 220 | amount1Desired, 221 | amount0Min: 0, 222 | amount1Min: 0, 223 | deadline: (await blockTimestamp()) + 1000, 224 | }) 225 | 226 | return { tokenId, lp: params.lp } 227 | } 228 | 229 | unstakeCollectBurnFlow: HelperTypes.UnstakeCollectBurn.Command = async (params) => { 230 | await this.staker.connect(params.lp).unstakeToken( 231 | incentiveResultToStakeAdapter(params.createIncentiveResult), 232 | params.tokenId, 233 | 234 | maxGas 235 | ) 236 | 237 | const unstakedAt = await blockTimestamp() 238 | 239 | await this.staker 240 | .connect(params.lp) 241 | .claimReward(params.createIncentiveResult.rewardToken.address, params.lp.address, BN('0')) 242 | 243 | await this.staker.connect(params.lp).withdrawToken(params.tokenId, params.lp.address, '0x', maxGas) 244 | 245 | const { liquidity } = await this.nft.connect(params.lp).positions(params.tokenId) 246 | 247 | await this.nft.connect(params.lp).decreaseLiquidity( 248 | { 249 | tokenId: params.tokenId, 250 | liquidity, 251 | amount0Min: 0, 252 | amount1Min: 0, 253 | deadline: (await blockTimestamp()) + 1000, 254 | }, 255 | maxGas 256 | ) 257 | 258 | const { tokensOwed0, tokensOwed1 } = await this.nft.connect(params.lp).positions(params.tokenId) 259 | 260 | await this.nft.connect(params.lp).collect( 261 | { 262 | tokenId: params.tokenId, 263 | recipient: params.lp.address, 264 | amount0Max: tokensOwed0, 265 | amount1Max: tokensOwed1, 266 | }, 267 | maxGas 268 | ) 269 | 270 | await this.nft.connect(params.lp).burn(params.tokenId, maxGas) 271 | 272 | const balance = await params.createIncentiveResult.rewardToken.connect(params.lp).balanceOf(params.lp.address) 273 | 274 | return { 275 | balance, 276 | unstakedAt, 277 | } 278 | } 279 | 280 | endIncentiveFlow: HelperTypes.EndIncentive.Command = async (params) => { 281 | const incentiveCreator = this.actors.incentiveCreator() 282 | const { rewardToken } = params.createIncentiveResult 283 | 284 | const receipt = await ( 285 | await this.staker.connect(incentiveCreator).endIncentive( 286 | _.assign({}, _.pick(params.createIncentiveResult, ['startTime', 'endTime']), { 287 | rewardToken: rewardToken.address, 288 | pool: params.createIncentiveResult.poolAddress, 289 | refundee: params.createIncentiveResult.refundee, 290 | }) 291 | ) 292 | ).wait() 293 | 294 | const transferFilter = rewardToken.filters.Transfer(this.staker.address, incentiveCreator.address, null) 295 | const transferTopic = rewardToken.interface.getEventTopic('Transfer') 296 | const logItem = receipt.logs.find((log) => log.topics.includes(transferTopic)) 297 | const events = await rewardToken.queryFilter(transferFilter, logItem?.blockHash) 298 | let amountTransferred: BigNumber 299 | 300 | if (events.length === 1) { 301 | amountTransferred = events[0].args[2] 302 | } else { 303 | throw new Error('Could not find transfer event') 304 | } 305 | 306 | return { 307 | amountReturnedToCreator: amountTransferred, 308 | } 309 | } 310 | 311 | getIncentiveId: HelperTypes.GetIncentiveId.Command = async (params) => { 312 | return this.testIncentiveId.compute({ 313 | rewardToken: params.rewardToken.address, 314 | pool: params.poolAddress, 315 | startTime: params.startTime, 316 | endTime: params.endTime, 317 | refundee: params.refundee, 318 | }) 319 | } 320 | 321 | makeTickGoFlow: HelperTypes.MakeTickGo.Command = async (params) => { 322 | // await tok0.transfer(trader0.address, BNe18(2).mul(params.numberOfTrades)) 323 | // await tok0 324 | // .connect(trader0) 325 | // .approve(router.address, BNe18(2).mul(params.numberOfTrades)) 326 | 327 | const MAKE_TICK_GO_UP = params.direction === 'up' 328 | const actor = params.trader || this.actors.traderUser0() 329 | 330 | const isDone = (tick: number | undefined) => { 331 | if (!params.desiredValue) { 332 | return true 333 | } else if (!tick) { 334 | return false 335 | } else if (MAKE_TICK_GO_UP) { 336 | return tick > params.desiredValue 337 | } else { 338 | return tick < params.desiredValue 339 | } 340 | } 341 | 342 | const [tok0Address, tok1Address] = await Promise.all([ 343 | this.pool.connect(actor).token0(), 344 | this.pool.connect(actor).token1(), 345 | ]) 346 | const erc20 = await ethers.getContractFactory('TestERC20') 347 | 348 | const tok0 = erc20.attach(tok0Address) as TestERC20 349 | const tok1 = erc20.attach(tok1Address) as TestERC20 350 | 351 | const doTrade = async () => { 352 | /* If we want to push price down, we need to increase tok0. 353 | If we want to push price up, we need to increase tok1 */ 354 | 355 | const amountIn = BNe18(1) 356 | 357 | const erc20Helper = new ERC20Helper() 358 | await erc20Helper.ensureBalancesAndApprovals(actor, [tok0, tok1], amountIn, this.router.address) 359 | 360 | const path = encodePath(MAKE_TICK_GO_UP ? [tok1Address, tok0Address] : [tok0Address, tok1Address], [ 361 | FeeAmount.MEDIUM, 362 | ]) 363 | 364 | await this.router.connect(actor).exactInput( 365 | { 366 | recipient: actor.address, 367 | deadline: MaxUint256, 368 | path, 369 | amountIn: amountIn.div(10), 370 | amountOutMinimum: 0, 371 | }, 372 | maxGas 373 | ) 374 | 375 | return await getCurrentTick(this.pool.connect(actor)) 376 | } 377 | 378 | let currentTick = await doTrade() 379 | 380 | while (!isDone(currentTick)) { 381 | currentTick = await doTrade() 382 | } 383 | 384 | return { currentTick } 385 | } 386 | } 387 | 388 | export class ERC20Helper { 389 | ensureBalancesAndApprovals = async ( 390 | actor: Wallet, 391 | tokens: TestERC20 | Array, 392 | balance: BigNumber, 393 | spender?: string 394 | ) => { 395 | for (let token of arrayWrap(tokens)) { 396 | await this.ensureBalance(actor, token, balance) 397 | if (spender) { 398 | await this.ensureApproval(actor, token, balance, spender) 399 | } 400 | } 401 | } 402 | 403 | ensureBalance = async (actor: Wallet, token: TestERC20, balance: BigNumber) => { 404 | const currentBalance = await token.balanceOf(actor.address) 405 | if (currentBalance.lt(balance)) { 406 | await token 407 | // .connect(this.actors.tokensOwner()) 408 | .transfer(actor.address, balance.sub(currentBalance)) 409 | } 410 | 411 | // if (spender) { 412 | // await this.ensureApproval(actor, token, balance, spender) 413 | // } 414 | 415 | return await token.balanceOf(actor.address) 416 | } 417 | 418 | ensureApproval = async (actor: Wallet, token: TestERC20, balance: BigNumber, spender: string) => { 419 | const currentAllowance = await token.allowance(actor.address, actor.address) 420 | if (currentAllowance.lt(balance)) { 421 | await token.connect(actor).approve(spender, balance) 422 | } 423 | } 424 | } 425 | 426 | type IncentiveAdapterFunc = (params: HelperTypes.CreateIncentive.Result) => ContractParams.IncentiveKey 427 | 428 | export const incentiveResultToStakeAdapter: IncentiveAdapterFunc = (params) => ({ 429 | pool: params.poolAddress, 430 | startTime: params.startTime, 431 | endTime: params.endTime, 432 | rewardToken: params.rewardToken.address, 433 | refundee: params.refundee, 434 | }) 435 | -------------------------------------------------------------------------------- /test/helpers/types.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, Wallet } from 'ethers' 2 | import { TestERC20 } from '../../typechain' 3 | import { FeeAmount } from '../shared' 4 | 5 | export module HelperTypes { 6 | export type CommandFunction = (input: Input) => Promise 7 | 8 | export module CreateIncentive { 9 | export type Args = { 10 | rewardToken: TestERC20 11 | poolAddress: string 12 | startTime: number 13 | endTime?: number 14 | totalReward: BigNumber 15 | refundee?: string 16 | } 17 | export type Result = { 18 | poolAddress: string 19 | rewardToken: TestERC20 20 | totalReward: BigNumber 21 | startTime: number 22 | endTime: number 23 | refundee: string 24 | } 25 | 26 | export type Command = CommandFunction 27 | } 28 | 29 | export module MintDepositStake { 30 | export type Args = { 31 | lp: Wallet 32 | tokensToStake: [TestERC20, TestERC20] 33 | amountsToStake: [BigNumber, BigNumber] 34 | ticks: [number, number] 35 | createIncentiveResult: CreateIncentive.Result 36 | } 37 | 38 | export type Result = { 39 | lp: Wallet 40 | tokenId: string 41 | stakedAt: number 42 | } 43 | 44 | export type Command = CommandFunction 45 | } 46 | 47 | export module Mint { 48 | type Args = { 49 | lp: Wallet 50 | tokens: [TestERC20, TestERC20] 51 | amounts?: [BigNumber, BigNumber] 52 | fee?: FeeAmount 53 | tickLower?: number 54 | tickUpper?: number 55 | } 56 | 57 | export type Result = { 58 | lp: Wallet 59 | tokenId: string 60 | } 61 | 62 | export type Command = CommandFunction 63 | } 64 | 65 | export module Deposit { 66 | type Args = { 67 | lp: Wallet 68 | tokenId: string 69 | } 70 | type Result = void 71 | export type Command = CommandFunction 72 | } 73 | 74 | export module UnstakeCollectBurn { 75 | type Args = { 76 | lp: Wallet 77 | tokenId: string 78 | createIncentiveResult: CreateIncentive.Result 79 | } 80 | export type Result = { 81 | balance: BigNumber 82 | unstakedAt: number 83 | } 84 | 85 | export type Command = CommandFunction 86 | } 87 | 88 | export module EndIncentive { 89 | type Args = { 90 | createIncentiveResult: CreateIncentive.Result 91 | } 92 | 93 | type Result = { 94 | amountReturnedToCreator: BigNumber 95 | } 96 | 97 | export type Command = CommandFunction 98 | } 99 | 100 | export module MakeTickGo { 101 | type Args = { 102 | direction: 'up' | 'down' 103 | desiredValue?: number 104 | trader?: Wallet 105 | } 106 | 107 | type Result = { currentTick: number } 108 | 109 | export type Command = CommandFunction 110 | } 111 | 112 | export module GetIncentiveId { 113 | type Args = CreateIncentive.Result 114 | 115 | // Returns the incentiveId as bytes32 116 | type Result = string 117 | 118 | export type Command = CommandFunction 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/matchers/beWithin.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, BigNumberish } from '@ethersproject/bignumber' 2 | import chai from 'chai' 3 | 4 | const { expect } = chai 5 | 6 | const BN = BigNumber.from 7 | 8 | declare global { 9 | module Chai { 10 | interface Assertion { 11 | beWithin(marginOfError: BigNumberish, actual: BigNumberish): Assertion 12 | } 13 | } 14 | } 15 | 16 | chai.use(({ Assertion }) => { 17 | Assertion.addMethod('beWithin', function (marginOfError: BigNumberish, actual: BigNumberish) { 18 | const result = BN(this._obj).abs().sub(BN(actual).abs()).lte(BN(marginOfError)) 19 | 20 | new Assertion(result, `Expected ${this._obj} to be within ${marginOfError} of ${actual}`) 21 | }) 22 | }) 23 | 24 | describe('BigNumber beWithin', () => { 25 | it('works', () => { 26 | expect(BN('100')).to.beWithin(BN('1'), BN('99')) 27 | expect(BN('100')).not.to.beWithin(BN('1'), BN('98')) 28 | expect(BN('100')).to.beWithin(BN('1'), BN('101')) 29 | expect(BN('100')).not.to.beWithin(BN('1'), BN('102')) 30 | expect(BN('10')).not.to.beWithin(BN('1'), BN('2')) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/shared/actors.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from 'ethereum-waffle' 2 | import { Wallet } from 'ethers' 3 | 4 | export const WALLET_USER_INDEXES = { 5 | WETH_OWNER: 0, 6 | TOKENS_OWNER: 1, 7 | UNISWAP_ROOT: 2, 8 | STAKER_DEPLOYER: 3, 9 | LP_USER_0: 4, 10 | LP_USER_1: 5, 11 | LP_USER_2: 6, 12 | TRADER_USER_0: 7, 13 | TRADER_USER_1: 8, 14 | TRADER_USER_2: 9, 15 | INCENTIVE_CREATOR: 10, 16 | } 17 | 18 | export class ActorFixture { 19 | wallets: Array 20 | provider: MockProvider 21 | 22 | constructor(wallets, provider) { 23 | this.wallets = wallets 24 | this.provider = provider 25 | } 26 | /* EOA that owns all Uniswap-related contracts */ 27 | 28 | /* EOA that mints and transfers WETH to test accounts */ 29 | wethOwner() { 30 | return this._getActor(WALLET_USER_INDEXES.WETH_OWNER) 31 | } 32 | 33 | /* EOA that mints all the Test ERC20s we use */ 34 | tokensOwner() { 35 | return this._getActor(WALLET_USER_INDEXES.TOKENS_OWNER) 36 | } 37 | 38 | uniswapRootUser() { 39 | return this._getActor(WALLET_USER_INDEXES.UNISWAP_ROOT) 40 | } 41 | 42 | /* EOA that will deploy the staker */ 43 | stakerDeployer() { 44 | return this._getActor(WALLET_USER_INDEXES.STAKER_DEPLOYER) 45 | } 46 | 47 | /* These EOAs provide liquidity in pools and collect fees/staking incentives */ 48 | lpUser0() { 49 | return this._getActor(WALLET_USER_INDEXES.LP_USER_0) 50 | } 51 | 52 | lpUser1() { 53 | return this._getActor(WALLET_USER_INDEXES.LP_USER_1) 54 | } 55 | 56 | lpUser2() { 57 | return this._getActor(WALLET_USER_INDEXES.LP_USER_2) 58 | } 59 | 60 | lpUsers() { 61 | return [this.lpUser0(), this.lpUser1(), this.lpUser2()] 62 | } 63 | 64 | /* These EOAs trade in the uniswap pools and incur fees */ 65 | traderUser0() { 66 | return this._getActor(WALLET_USER_INDEXES.TRADER_USER_0) 67 | } 68 | 69 | traderUser1() { 70 | return this._getActor(WALLET_USER_INDEXES.TRADER_USER_1) 71 | } 72 | 73 | traderUser2() { 74 | return this._getActor(WALLET_USER_INDEXES.TRADER_USER_2) 75 | } 76 | 77 | incentiveCreator() { 78 | return this._getActor(WALLET_USER_INDEXES.INCENTIVE_CREATOR) 79 | } 80 | 81 | private _getActor(index: number): Wallet { 82 | /* Actual logic for fetching the wallet */ 83 | if (!index) { 84 | throw new Error(`Invalid index: ${index}`) 85 | } 86 | const account = this.wallets[index] 87 | if (!account) { 88 | throw new Error(`Account ID ${index} could not be loaded`) 89 | } 90 | return account 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/shared/external/WETH9.json: -------------------------------------------------------------------------------- 1 | { 2 | "bytecode": "60606040526040805190810160405280600d81526020017f57726170706564204574686572000000000000000000000000000000000000008152506000908051906020019061004f9291906100c8565b506040805190810160405280600481526020017f57455448000000000000000000000000000000000000000000000000000000008152506001908051906020019061009b9291906100c8565b506012600260006101000a81548160ff021916908360ff16021790555034156100c357600080fd5b61016d565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f1061010957805160ff1916838001178555610137565b82800160010185558215610137579182015b8281111561013657825182559160200191906001019061011b565b5b5090506101449190610148565b5090565b61016a91905b8082111561016657600081600090555060010161014e565b5090565b90565b610c348061017c6000396000f3006060604052600436106100af576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806306fdde03146100b9578063095ea7b31461014757806318160ddd146101a157806323b872dd146101ca5780632e1a7d4d14610243578063313ce5671461026657806370a082311461029557806395d89b41146102e2578063a9059cbb14610370578063d0e30db0146103ca578063dd62ed3e146103d4575b6100b7610440565b005b34156100c457600080fd5b6100cc6104dd565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561010c5780820151818401526020810190506100f1565b50505050905090810190601f1680156101395780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561015257600080fd5b610187600480803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803590602001909190505061057b565b604051808215151515815260200191505060405180910390f35b34156101ac57600080fd5b6101b461066d565b6040518082815260200191505060405180910390f35b34156101d557600080fd5b610229600480803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803590602001909190505061068c565b604051808215151515815260200191505060405180910390f35b341561024e57600080fd5b61026460048080359060200190919050506109d9565b005b341561027157600080fd5b610279610b05565b604051808260ff1660ff16815260200191505060405180910390f35b34156102a057600080fd5b6102cc600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610b18565b6040518082815260200191505060405180910390f35b34156102ed57600080fd5b6102f5610b30565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561033557808201518184015260208101905061031a565b50505050905090810190601f1680156103625780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561037b57600080fd5b6103b0600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091908035906020019091905050610bce565b604051808215151515815260200191505060405180910390f35b6103d2610440565b005b34156103df57600080fd5b61042a600480803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610be3565b6040518082815260200191505060405180910390f35b34600360003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825401925050819055503373ffffffffffffffffffffffffffffffffffffffff167fe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c346040518082815260200191505060405180910390a2565b60008054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156105735780601f1061054857610100808354040283529160200191610573565b820191906000526020600020905b81548152906001019060200180831161055657829003601f168201915b505050505081565b600081600460003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925846040518082815260200191505060405180910390a36001905092915050565b60003073ffffffffffffffffffffffffffffffffffffffff1631905090565b600081600360008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054101515156106dc57600080fd5b3373ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff16141580156107b457507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600460008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205414155b156108cf5781600460008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020541015151561084457600080fd5b81600460008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825403925050819055505b81600360008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000828254039250508190555081600360008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825401925050819055508273ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040518082815260200191505060405180910390a3600190509392505050565b80600360003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410151515610a2757600080fd5b80600360003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825403925050819055503373ffffffffffffffffffffffffffffffffffffffff166108fc829081150290604051600060405180830381858888f193505050501515610ab457600080fd5b3373ffffffffffffffffffffffffffffffffffffffff167f7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65826040518082815260200191505060405180910390a250565b600260009054906101000a900460ff1681565b60036020528060005260406000206000915090505481565b60018054600181600116156101000203166002900480601f016020809104026020016040519081016040528092919081815260200182805460018160011615610100020316600290048015610bc65780601f10610b9b57610100808354040283529160200191610bc6565b820191906000526020600020905b815481529060010190602001808311610ba957829003601f168201915b505050505081565b6000610bdb33848461068c565b905092915050565b60046020528160005260406000206020528060005260406000206000915091505054815600a165627a7a72305820deb4c2ccab3c2fdca32ab3f46728389c2fe2c165d5fafa07661e4e004f6c344a0029", 3 | "abi": [ 4 | { 5 | "constant": true, 6 | "inputs": [], 7 | "name": "name", 8 | "outputs": [{ "name": "", "type": "string" }], 9 | "payable": false, 10 | "stateMutability": "view", 11 | "type": "function" 12 | }, 13 | { 14 | "constant": false, 15 | "inputs": [ 16 | { "name": "guy", "type": "address" }, 17 | { "name": "wad", "type": "uint256" } 18 | ], 19 | "name": "approve", 20 | "outputs": [{ "name": "", "type": "bool" }], 21 | "payable": false, 22 | "stateMutability": "nonpayable", 23 | "type": "function" 24 | }, 25 | { 26 | "constant": true, 27 | "inputs": [], 28 | "name": "totalSupply", 29 | "outputs": [{ "name": "", "type": "uint256" }], 30 | "payable": false, 31 | "stateMutability": "view", 32 | "type": "function" 33 | }, 34 | { 35 | "constant": false, 36 | "inputs": [ 37 | { "name": "src", "type": "address" }, 38 | { "name": "dst", "type": "address" }, 39 | { "name": "wad", "type": "uint256" } 40 | ], 41 | "name": "transferFrom", 42 | "outputs": [{ "name": "", "type": "bool" }], 43 | "payable": false, 44 | "stateMutability": "nonpayable", 45 | "type": "function" 46 | }, 47 | { 48 | "constant": false, 49 | "inputs": [{ "name": "wad", "type": "uint256" }], 50 | "name": "withdraw", 51 | "outputs": [], 52 | "payable": false, 53 | "stateMutability": "nonpayable", 54 | "type": "function" 55 | }, 56 | { 57 | "constant": true, 58 | "inputs": [], 59 | "name": "decimals", 60 | "outputs": [{ "name": "", "type": "uint8" }], 61 | "payable": false, 62 | "stateMutability": "view", 63 | "type": "function" 64 | }, 65 | { 66 | "constant": true, 67 | "inputs": [{ "name": "", "type": "address" }], 68 | "name": "balanceOf", 69 | "outputs": [{ "name": "", "type": "uint256" }], 70 | "payable": false, 71 | "stateMutability": "view", 72 | "type": "function" 73 | }, 74 | { 75 | "constant": true, 76 | "inputs": [], 77 | "name": "symbol", 78 | "outputs": [{ "name": "", "type": "string" }], 79 | "payable": false, 80 | "stateMutability": "view", 81 | "type": "function" 82 | }, 83 | { 84 | "constant": false, 85 | "inputs": [ 86 | { "name": "dst", "type": "address" }, 87 | { "name": "wad", "type": "uint256" } 88 | ], 89 | "name": "transfer", 90 | "outputs": [{ "name": "", "type": "bool" }], 91 | "payable": false, 92 | "stateMutability": "nonpayable", 93 | "type": "function" 94 | }, 95 | { 96 | "constant": false, 97 | "inputs": [], 98 | "name": "deposit", 99 | "outputs": [], 100 | "payable": true, 101 | "stateMutability": "payable", 102 | "type": "function" 103 | }, 104 | { 105 | "constant": true, 106 | "inputs": [ 107 | { "name": "", "type": "address" }, 108 | { "name": "", "type": "address" } 109 | ], 110 | "name": "allowance", 111 | "outputs": [{ "name": "", "type": "uint256" }], 112 | "payable": false, 113 | "stateMutability": "view", 114 | "type": "function" 115 | }, 116 | { "payable": true, "stateMutability": "payable", "type": "fallback" }, 117 | { 118 | "anonymous": false, 119 | "inputs": [ 120 | { "indexed": true, "name": "src", "type": "address" }, 121 | { "indexed": true, "name": "guy", "type": "address" }, 122 | { "indexed": false, "name": "wad", "type": "uint256" } 123 | ], 124 | "name": "Approval", 125 | "type": "event" 126 | }, 127 | { 128 | "anonymous": false, 129 | "inputs": [ 130 | { "indexed": true, "name": "src", "type": "address" }, 131 | { "indexed": true, "name": "dst", "type": "address" }, 132 | { "indexed": false, "name": "wad", "type": "uint256" } 133 | ], 134 | "name": "Transfer", 135 | "type": "event" 136 | }, 137 | { 138 | "anonymous": false, 139 | "inputs": [ 140 | { "indexed": true, "name": "dst", "type": "address" }, 141 | { "indexed": false, "name": "wad", "type": "uint256" } 142 | ], 143 | "name": "Deposit", 144 | "type": "event" 145 | }, 146 | { 147 | "anonymous": false, 148 | "inputs": [ 149 | { "indexed": true, "name": "src", "type": "address" }, 150 | { "indexed": false, "name": "wad", "type": "uint256" } 151 | ], 152 | "name": "Withdrawal", 153 | "type": "event" 154 | } 155 | ] 156 | } 157 | -------------------------------------------------------------------------------- /test/shared/external/v3-periphery/constants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/Uniswap/uniswap-v3-periphery/blob/e3fb908f1fbc72f1b1342c983c9ad756448c3bba/test/shared/constants.ts 3 | */ 4 | 5 | import { BigNumber } from 'ethers' 6 | 7 | export const MaxUint128 = BigNumber.from(2).pow(128).sub(1) 8 | 9 | export enum FeeAmount { 10 | LOW = 500, 11 | MEDIUM = 3000, 12 | HIGH = 10000, 13 | } 14 | 15 | export const TICK_SPACINGS: { [amount in FeeAmount]: number } = { 16 | [FeeAmount.LOW]: 10, 17 | [FeeAmount.MEDIUM]: 60, 18 | [FeeAmount.HIGH]: 200, 19 | } 20 | -------------------------------------------------------------------------------- /test/shared/external/v3-periphery/ticks.ts: -------------------------------------------------------------------------------- 1 | /* https://github.com/Uniswap/uniswap-v3-periphery/blob/e3fb908f1fbc72f1b1342c983c9ad756448c3bba/test/shared/ticks.ts */ 2 | 3 | import { BigNumber } from 'ethers' 4 | 5 | export const getMinTick = (tickSpacing: number) => Math.ceil(-887272 / tickSpacing) * tickSpacing 6 | 7 | export const getMaxTick = (tickSpacing: number) => Math.floor(887272 / tickSpacing) * tickSpacing 8 | 9 | export const getMaxLiquidityPerTick = (tickSpacing: number) => 10 | BigNumber.from(2) 11 | .pow(128) 12 | .sub(1) 13 | .div((getMaxTick(tickSpacing) - getMinTick(tickSpacing)) / tickSpacing + 1) 14 | -------------------------------------------------------------------------------- /test/shared/external/v3-periphery/tokenSort.ts: -------------------------------------------------------------------------------- 1 | /* https://github.com/Uniswap/uniswap-v3-periphery/blob/710d51dca94e1feeee9b039a9bc4428ff80f7232/test/shared/tokenSort.ts */ 2 | 3 | export function compareToken(a: { address: string }, b: { address: string }): -1 | 1 { 4 | return a.address.toLowerCase() < b.address.toLowerCase() ? -1 : 1 5 | } 6 | 7 | export function sortedTokens( 8 | a: { address: string }, 9 | b: { address: string } 10 | ): [typeof a, typeof b] | [typeof b, typeof a] { 11 | return compareToken(a, b) < 0 ? [a, b] : [b, a] 12 | } 13 | -------------------------------------------------------------------------------- /test/shared/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { Fixture } from 'ethereum-waffle' 2 | import { constants } from 'ethers' 3 | import { ethers, waffle } from 'hardhat' 4 | 5 | import UniswapV3Pool from '@uniswap/v3-core/artifacts/contracts/UniswapV3Pool.sol/UniswapV3Pool.json' 6 | import UniswapV3FactoryJson from '@uniswap/v3-core/artifacts/contracts/UniswapV3Factory.sol/UniswapV3Factory.json' 7 | import NFTDescriptorJson from '@uniswap/v3-periphery/artifacts/contracts/libraries/NFTDescriptor.sol/NFTDescriptor.json' 8 | import NonfungiblePositionManagerJson from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json' 9 | import NonfungibleTokenPositionDescriptor from '@uniswap/v3-periphery/artifacts/contracts/NonfungibleTokenPositionDescriptor.sol/NonfungibleTokenPositionDescriptor.json' 10 | import SwapRouter from '@uniswap/v3-periphery/artifacts/contracts/SwapRouter.sol/SwapRouter.json' 11 | import WETH9 from './external/WETH9.json' 12 | import { linkLibraries } from './linkLibraries' 13 | import { ISwapRouter } from '../../types/ISwapRouter' 14 | import { IWETH9 } from '../../types/IWETH9' 15 | import { 16 | UniswapV3Staker, 17 | TestERC20, 18 | INonfungiblePositionManager, 19 | IUniswapV3Factory, 20 | IUniswapV3Pool, 21 | TestIncentiveId, 22 | } from '../../typechain' 23 | import { NFTDescriptor } from '../../types/NFTDescriptor' 24 | import { FeeAmount, BigNumber, encodePriceSqrt, MAX_GAS_LIMIT } from '../shared' 25 | import { ActorFixture } from './actors' 26 | 27 | type WETH9Fixture = { weth9: IWETH9 } 28 | 29 | export const wethFixture: Fixture = async ([wallet]) => { 30 | const weth9 = (await waffle.deployContract(wallet, { 31 | bytecode: WETH9.bytecode, 32 | abi: WETH9.abi, 33 | })) as IWETH9 34 | 35 | return { weth9 } 36 | } 37 | 38 | const v3CoreFactoryFixture: Fixture = async ([wallet]) => { 39 | return ((await waffle.deployContract(wallet, { 40 | bytecode: UniswapV3FactoryJson.bytecode, 41 | abi: UniswapV3FactoryJson.abi, 42 | })) as unknown) as IUniswapV3Factory 43 | } 44 | 45 | export const v3RouterFixture: Fixture<{ 46 | weth9: IWETH9 47 | factory: IUniswapV3Factory 48 | router: ISwapRouter 49 | }> = async ([wallet], provider) => { 50 | const { weth9 } = await wethFixture([wallet], provider) 51 | const factory = await v3CoreFactoryFixture([wallet], provider) 52 | const router = ((await waffle.deployContract( 53 | wallet, 54 | { 55 | bytecode: SwapRouter.bytecode, 56 | abi: SwapRouter.abi, 57 | }, 58 | [factory.address, weth9.address] 59 | )) as unknown) as ISwapRouter 60 | 61 | return { factory, weth9, router } 62 | } 63 | 64 | const nftDescriptorLibraryFixture: Fixture = async ([wallet]) => { 65 | return (await waffle.deployContract(wallet, { 66 | bytecode: NFTDescriptorJson.bytecode, 67 | abi: NFTDescriptorJson.abi, 68 | })) as NFTDescriptor 69 | } 70 | 71 | type UniswapFactoryFixture = { 72 | weth9: IWETH9 73 | factory: IUniswapV3Factory 74 | router: ISwapRouter 75 | nft: INonfungiblePositionManager 76 | tokens: [TestERC20, TestERC20, TestERC20] 77 | } 78 | 79 | export const uniswapFactoryFixture: Fixture = async (wallets, provider) => { 80 | const { weth9, factory, router } = await v3RouterFixture(wallets, provider) 81 | const tokenFactory = await ethers.getContractFactory('TestERC20') 82 | const tokens = (await Promise.all([ 83 | tokenFactory.deploy(constants.MaxUint256.div(2)), // do not use maxu256 to avoid overflowing 84 | tokenFactory.deploy(constants.MaxUint256.div(2)), 85 | tokenFactory.deploy(constants.MaxUint256.div(2)), 86 | ])) as [TestERC20, TestERC20, TestERC20] 87 | 88 | const nftDescriptorLibrary = await nftDescriptorLibraryFixture(wallets, provider) 89 | 90 | const linkedBytecode = linkLibraries( 91 | { 92 | bytecode: NonfungibleTokenPositionDescriptor.bytecode, 93 | linkReferences: { 94 | 'NFTDescriptor.sol': { 95 | NFTDescriptor: [ 96 | { 97 | length: 20, 98 | start: 1261, 99 | }, 100 | ], 101 | }, 102 | }, 103 | }, 104 | { 105 | NFTDescriptor: nftDescriptorLibrary.address, 106 | } 107 | ) 108 | 109 | const positionDescriptor = await waffle.deployContract( 110 | wallets[0], 111 | { 112 | bytecode: linkedBytecode, 113 | abi: NonfungibleTokenPositionDescriptor.abi, 114 | }, 115 | [tokens[0].address] 116 | ) 117 | 118 | const nftFactory = new ethers.ContractFactory( 119 | NonfungiblePositionManagerJson.abi, 120 | NonfungiblePositionManagerJson.bytecode, 121 | wallets[0] 122 | ) 123 | const nft = (await nftFactory.deploy( 124 | factory.address, 125 | weth9.address, 126 | positionDescriptor.address 127 | )) as INonfungiblePositionManager 128 | 129 | tokens.sort((a, b) => (a.address.toLowerCase() < b.address.toLowerCase() ? -1 : 1)) 130 | 131 | return { 132 | weth9, 133 | factory, 134 | router, 135 | tokens, 136 | nft, 137 | } 138 | } 139 | 140 | export const mintPosition = async ( 141 | nft: INonfungiblePositionManager, 142 | mintParams: { 143 | token0: string 144 | token1: string 145 | fee: FeeAmount 146 | tickLower: number 147 | tickUpper: number 148 | recipient: string 149 | amount0Desired: any 150 | amount1Desired: any 151 | amount0Min: number 152 | amount1Min: number 153 | deadline: number 154 | } 155 | ): Promise => { 156 | const transferFilter = nft.filters.Transfer(null, null, null) 157 | const transferTopic = nft.interface.getEventTopic('Transfer') 158 | 159 | let tokenId: BigNumber | undefined 160 | 161 | const receipt = await ( 162 | await nft.mint( 163 | { 164 | token0: mintParams.token0, 165 | token1: mintParams.token1, 166 | fee: mintParams.fee, 167 | tickLower: mintParams.tickLower, 168 | tickUpper: mintParams.tickUpper, 169 | recipient: mintParams.recipient, 170 | amount0Desired: mintParams.amount0Desired, 171 | amount1Desired: mintParams.amount1Desired, 172 | amount0Min: mintParams.amount0Min, 173 | amount1Min: mintParams.amount1Min, 174 | deadline: mintParams.deadline, 175 | }, 176 | { 177 | gasLimit: MAX_GAS_LIMIT, 178 | } 179 | ) 180 | ).wait() 181 | 182 | for (let i = 0; i < receipt.logs.length; i++) { 183 | const log = receipt.logs[i] 184 | if (log.address === nft.address && log.topics.includes(transferTopic)) { 185 | // for some reason log.data is 0x so this hack just re-fetches it 186 | const events = await nft.queryFilter(transferFilter, log.blockHash) 187 | if (events.length === 1) { 188 | tokenId = events[0].args?.tokenId 189 | } 190 | break 191 | } 192 | } 193 | 194 | if (tokenId === undefined) { 195 | throw 'could not find tokenId after mint' 196 | } else { 197 | return tokenId.toString() 198 | } 199 | } 200 | 201 | export type UniswapFixtureType = { 202 | factory: IUniswapV3Factory 203 | fee: FeeAmount 204 | nft: INonfungiblePositionManager 205 | pool01: string 206 | pool12: string 207 | poolObj: IUniswapV3Pool 208 | router: ISwapRouter 209 | staker: UniswapV3Staker 210 | testIncentiveId: TestIncentiveId 211 | tokens: [TestERC20, TestERC20, TestERC20] 212 | token0: TestERC20 213 | token1: TestERC20 214 | rewardToken: TestERC20 215 | } 216 | export const uniswapFixture: Fixture = async (wallets, provider) => { 217 | const { tokens, nft, factory, router } = await uniswapFactoryFixture(wallets, provider) 218 | const signer = new ActorFixture(wallets, provider).stakerDeployer() 219 | const stakerFactory = await ethers.getContractFactory('UniswapV3Staker', signer) 220 | const staker = (await stakerFactory.deploy(factory.address, nft.address, 2 ** 32, 2 ** 32)) as UniswapV3Staker 221 | 222 | const testIncentiveIdFactory = await ethers.getContractFactory('TestIncentiveId', signer) 223 | const testIncentiveId = (await testIncentiveIdFactory.deploy()) as TestIncentiveId 224 | 225 | for (const token of tokens) { 226 | await token.approve(nft.address, constants.MaxUint256) 227 | } 228 | 229 | const fee = FeeAmount.MEDIUM 230 | await nft.createAndInitializePoolIfNecessary(tokens[0].address, tokens[1].address, fee, encodePriceSqrt(1, 1)) 231 | 232 | await nft.createAndInitializePoolIfNecessary(tokens[1].address, tokens[2].address, fee, encodePriceSqrt(1, 1)) 233 | 234 | const pool01 = await factory.getPool(tokens[0].address, tokens[1].address, fee) 235 | 236 | const pool12 = await factory.getPool(tokens[1].address, tokens[2].address, fee) 237 | 238 | const poolObj = poolFactory.attach(pool01) as IUniswapV3Pool 239 | 240 | return { 241 | nft, 242 | router, 243 | tokens, 244 | staker, 245 | testIncentiveId, 246 | factory, 247 | pool01, 248 | pool12, 249 | fee, 250 | poolObj, 251 | token0: tokens[0], 252 | token1: tokens[1], 253 | rewardToken: tokens[2], 254 | } 255 | } 256 | 257 | export const poolFactory = new ethers.ContractFactory(UniswapV3Pool.abi, UniswapV3Pool.bytecode) 258 | -------------------------------------------------------------------------------- /test/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './external/v3-periphery/constants' 2 | export * from './external/v3-periphery/ticks' 3 | export * from './external/v3-periphery/tokenSort' 4 | export * from './fixtures' 5 | export * from './actors' 6 | export * from './logging' 7 | export * from './ticks' 8 | 9 | import { FeeAmount } from './external/v3-periphery/constants' 10 | import { provider } from './provider' 11 | import { BigNumber, BigNumberish, Contract, ContractTransaction } from 'ethers' 12 | import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider' 13 | import { constants } from 'ethers' 14 | 15 | import bn from 'bignumber.js' 16 | 17 | import { expect, use } from 'chai' 18 | import { solidity } from 'ethereum-waffle' 19 | import { jestSnapshotPlugin } from 'mocha-chai-jest-snapshot' 20 | 21 | import { IUniswapV3Pool, TestERC20 } from '../../typechain' 22 | import { isArray, isString } from 'lodash' 23 | import { ethers } from 'hardhat' 24 | 25 | export const { MaxUint256 } = constants 26 | 27 | export const blockTimestamp = async () => { 28 | const block = await provider.getBlock('latest') 29 | if (!block) { 30 | throw new Error('null block returned from provider') 31 | } 32 | return block.timestamp 33 | } 34 | 35 | bn.config({ EXPONENTIAL_AT: 999999, DECIMAL_PLACES: 40 }) 36 | 37 | use(solidity) 38 | use(jestSnapshotPlugin()) 39 | 40 | export { expect } 41 | 42 | // returns the sqrt price as a 64x96 43 | export const encodePriceSqrt = (reserve1: BigNumberish, reserve0: BigNumberish): BigNumber => { 44 | return BigNumber.from( 45 | new bn(reserve1.toString()) 46 | .div(reserve0.toString()) 47 | .sqrt() 48 | .multipliedBy(new bn(2).pow(96)) 49 | .integerValue(3) 50 | .toString() 51 | ) 52 | } 53 | 54 | export const BN = BigNumber.from 55 | export const BNe = (n: BigNumberish, exponent: BigNumberish) => BN(n).mul(BN(10).pow(exponent)) 56 | export const BNe18 = (n: BigNumberish) => BNe(n, 18) 57 | 58 | export const divE18 = (n: BigNumber) => n.div(BNe18('1')).toNumber() 59 | export const ratioE18 = (a: BigNumber, b: BigNumber) => (divE18(a) / divE18(b)).toFixed(2) 60 | 61 | const bigNumberSum = (arr: Array) => arr.reduce((acc, item) => acc.add(item), BN('0')) 62 | 63 | export const bnSum = bigNumberSum 64 | 65 | export { BigNumber, BigNumberish } from 'ethers' 66 | 67 | export async function snapshotGasCost( 68 | x: 69 | | TransactionResponse 70 | | Promise 71 | | ContractTransaction 72 | | Promise 73 | | TransactionReceipt 74 | | Promise 75 | | BigNumber 76 | | Contract 77 | | Promise 78 | ): Promise { 79 | const resolved = await x 80 | if ('deployTransaction' in resolved) { 81 | const receipt = await resolved.deployTransaction.wait() 82 | expect(receipt.gasUsed.toNumber()).toMatchSnapshot() 83 | } else if ('wait' in resolved) { 84 | const waited = await resolved.wait() 85 | expect(waited.gasUsed.toNumber()).toMatchSnapshot() 86 | } else if (BigNumber.isBigNumber(resolved)) { 87 | expect(resolved.toNumber()).toMatchSnapshot() 88 | } 89 | } 90 | 91 | export function encodePath(path: string[], fees: FeeAmount[]): string { 92 | if (path.length != fees.length + 1) { 93 | throw new Error('path/fee lengths do not match') 94 | } 95 | 96 | let encoded = '0x' 97 | for (let i = 0; i < fees.length; i++) { 98 | // 20 byte encoding of the address 99 | encoded += path[i].slice(2) 100 | // 3 byte encoding of the fee 101 | encoded += fees[i].toString(16).padStart(2 * 3, '0') 102 | } 103 | // encode the final token 104 | encoded += path[path.length - 1].slice(2) 105 | 106 | return encoded.toLowerCase() 107 | } 108 | 109 | export const MIN_SQRT_RATIO = BigNumber.from('4295128739') 110 | export const MAX_SQRT_RATIO = BigNumber.from('1461446703485210103287273052203988822378723970342') 111 | 112 | export const MAX_GAS_LIMIT = 12_450_000 113 | export const maxGas = { 114 | gasLimit: MAX_GAS_LIMIT, 115 | } 116 | export const days = (n: number) => 86_400 * n 117 | 118 | export const getSlot0 = async (pool: IUniswapV3Pool) => { 119 | if (!pool.signer) { 120 | throw new Error('Cannot getSlot0 without a signer') 121 | } 122 | return await pool.slot0() 123 | } 124 | 125 | // This is currently lpUser0 but can be called from anybody. 126 | export const getCurrentTick = async (pool: IUniswapV3Pool): Promise => (await getSlot0(pool)).tick 127 | 128 | export const arrayWrap = (x: any) => { 129 | if (!isArray(x)) { 130 | return [x] 131 | } 132 | return x 133 | } 134 | 135 | export const erc20Wrap = async (x: string | TestERC20): Promise => { 136 | if (isString(x)) { 137 | const factory = await ethers.getContractFactory('TestERC20') 138 | return factory.attach(x) as TestERC20 139 | } 140 | return x 141 | } 142 | 143 | export const makeTimestamps = (n: number, duration: number = 1_000) => ({ 144 | startTime: n + 100, 145 | endTime: n + 100 + duration, 146 | }) 147 | -------------------------------------------------------------------------------- /test/shared/linkLibraries.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This is needed because there's currently no way in ethers.js to link a 3 | library when you're working with the contract ABI/bytecode. 4 | 5 | See https://github.com/ethers-io/ethers.js/issues/195 6 | */ 7 | 8 | import { utils } from 'ethers' 9 | 10 | export const linkLibraries = ( 11 | { 12 | bytecode, 13 | linkReferences, 14 | }: { 15 | bytecode: string 16 | linkReferences: { 17 | [fileName: string]: { 18 | [contractName: string]: { length: number; start: number }[] 19 | } 20 | } 21 | }, 22 | libraries: { [libraryName: string]: string } 23 | ): string => { 24 | Object.keys(linkReferences).forEach((fileName) => { 25 | Object.keys(linkReferences[fileName]).forEach((contractName) => { 26 | if (!libraries.hasOwnProperty(contractName)) { 27 | throw new Error(`Missing link library name ${contractName}`) 28 | } 29 | const address = utils.getAddress(libraries[contractName]).toLowerCase().slice(2) 30 | linkReferences[fileName][contractName].forEach(({ start: byteStart, length: byteLength }) => { 31 | const start = 2 + byteStart * 2 32 | const length = byteLength * 2 33 | bytecode = bytecode 34 | .slice(0, start) 35 | .concat(address) 36 | .concat(bytecode.slice(start + length, bytecode.length)) 37 | }) 38 | }) 39 | }) 40 | return bytecode 41 | } 42 | -------------------------------------------------------------------------------- /test/shared/logging.ts: -------------------------------------------------------------------------------- 1 | import createLogger, { LogLevelNames } from 'console-log-level' 2 | 3 | const level: LogLevelNames = 'info' 4 | 5 | export const log = createLogger({ level }) 6 | -------------------------------------------------------------------------------- /test/shared/provider.ts: -------------------------------------------------------------------------------- 1 | import { waffle } from 'hardhat' 2 | 3 | export const provider = waffle.provider 4 | export const createFixtureLoader = waffle.createFixtureLoader 5 | -------------------------------------------------------------------------------- /test/shared/ticks.ts: -------------------------------------------------------------------------------- 1 | import { FeeAmount } from './external/v3-periphery/constants' 2 | import { getMinTick, getMaxTick } from './external/v3-periphery/ticks' 3 | import { TICK_SPACINGS } from './external/v3-periphery/constants' 4 | 5 | export const defaultTicks = (fee: FeeAmount = FeeAmount.MEDIUM) => ({ 6 | tickLower: getMinTick(TICK_SPACINGS[fee]), 7 | tickUpper: getMaxTick(TICK_SPACINGS[fee]), 8 | }) 9 | 10 | export const defaultTicksArray = (...args): [number, number] => { 11 | const { tickLower, tickUpper } = defaultTicks(...args) 12 | return [tickLower, tickUpper] 13 | } 14 | -------------------------------------------------------------------------------- /test/shared/time.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from 'ethereum-waffle' 2 | import { log } from './logging' 3 | 4 | type TimeSetterFunction = (timestamp: number) => Promise 5 | 6 | type TimeSetters = { 7 | set: TimeSetterFunction 8 | step: TimeSetterFunction 9 | setAndMine: TimeSetterFunction 10 | } 11 | 12 | export const createTimeMachine = (provider: MockProvider): TimeSetters => { 13 | return { 14 | set: async (timestamp: number) => { 15 | log.debug(`🕒 setTime(${timestamp})`) 16 | // Not sure if I need both of those 17 | await provider.send('evm_setNextBlockTimestamp', [timestamp]) 18 | }, 19 | 20 | step: async (interval: number) => { 21 | log.debug(`🕒 increaseTime(${interval})`) 22 | await provider.send('evm_increaseTime', [interval]) 23 | }, 24 | 25 | setAndMine: async (timestamp: number) => { 26 | await provider.send('evm_setNextBlockTimestamp', [timestamp]) 27 | await provider.send('evm_mine', []) 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { createFixtureLoader } from './shared/provider' 4 | import { UniswapFixtureType } from './shared/fixtures' 5 | 6 | export type LoadFixtureFunction = ReturnType 7 | 8 | export type TestContext = UniswapFixtureType & { 9 | subject?: Function 10 | } 11 | -------------------------------------------------------------------------------- /test/unit/Deployment.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoadFixtureFunction } from '../types' 2 | import { ethers } from 'hardhat' 3 | import { UniswapV3Staker } from '../../typechain' 4 | import { uniswapFixture, UniswapFixtureType } from '../shared/fixtures' 5 | import { expect } from '../shared' 6 | import { createFixtureLoader, provider } from '../shared/provider' 7 | 8 | let loadFixture: LoadFixtureFunction 9 | 10 | describe('unit/Deployment', () => { 11 | let context: UniswapFixtureType 12 | 13 | before('loader', async () => { 14 | loadFixture = createFixtureLoader(provider.getWallets(), provider) 15 | }) 16 | 17 | beforeEach('create fixture loader', async () => { 18 | context = await loadFixture(uniswapFixture) 19 | }) 20 | 21 | it('deploys and has an address', async () => { 22 | const stakerFactory = await ethers.getContractFactory('UniswapV3Staker') 23 | const staker = (await stakerFactory.deploy( 24 | context.factory.address, 25 | context.nft.address, 26 | 2 ** 32, 27 | 2 ** 32 28 | )) as UniswapV3Staker 29 | expect(staker.address).to.be.a.string 30 | }) 31 | 32 | it('sets immutable variables', async () => { 33 | const stakerFactory = await ethers.getContractFactory('UniswapV3Staker') 34 | const staker = (await stakerFactory.deploy( 35 | context.factory.address, 36 | context.nft.address, 37 | 2 ** 32, 38 | 2 ** 32 39 | )) as UniswapV3Staker 40 | 41 | expect(await staker.factory()).to.equal(context.factory.address) 42 | expect(await staker.nonfungiblePositionManager()).to.equal(context.nft.address) 43 | expect(await staker.maxIncentiveDuration()).to.equal(2 ** 32) 44 | expect(await staker.maxIncentiveStartLeadTime()).to.equal(2 ** 32) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /test/unit/Deposits.spec.ts: -------------------------------------------------------------------------------- 1 | import { constants, BigNumberish, Wallet } from 'ethers' 2 | import { LoadFixtureFunction } from '../types' 3 | import { ethers } from 'hardhat' 4 | import { uniswapFixture, mintPosition, UniswapFixtureType } from '../shared/fixtures' 5 | import { 6 | expect, 7 | getMaxTick, 8 | getMinTick, 9 | FeeAmount, 10 | TICK_SPACINGS, 11 | blockTimestamp, 12 | BN, 13 | BNe18, 14 | snapshotGasCost, 15 | ActorFixture, 16 | makeTimestamps, 17 | maxGas, 18 | } from '../shared' 19 | import { createFixtureLoader, provider } from '../shared/provider' 20 | import { HelperCommands, ERC20Helper, incentiveResultToStakeAdapter } from '../helpers' 21 | 22 | import { ContractParams } from '../../types/contractParams' 23 | import { createTimeMachine } from '../shared/time' 24 | import { HelperTypes } from '../helpers/types' 25 | 26 | let loadFixture: LoadFixtureFunction 27 | 28 | describe('unit/Deposits', () => { 29 | const actors = new ActorFixture(provider.getWallets(), provider) 30 | const lpUser0 = actors.lpUser0() 31 | const amountDesired = BNe18(10) 32 | const totalReward = BNe18(100) 33 | const erc20Helper = new ERC20Helper() 34 | const Time = createTimeMachine(provider) 35 | let helpers: HelperCommands 36 | const incentiveCreator = actors.incentiveCreator() 37 | let context: UniswapFixtureType 38 | 39 | before('loader', async () => { 40 | loadFixture = createFixtureLoader(provider.getWallets(), provider) 41 | }) 42 | 43 | beforeEach('create fixture loader', async () => { 44 | context = await loadFixture(uniswapFixture) 45 | helpers = HelperCommands.fromTestContext(context, actors, provider) 46 | }) 47 | 48 | let subject: (tokenId: string, recipient: string) => Promise 49 | let tokenId: string 50 | let recipient = lpUser0.address 51 | 52 | const SAFE_TRANSFER_FROM_SIGNATURE = 'safeTransferFrom(address,address,uint256,bytes)' 53 | const INCENTIVE_KEY_ABI = 54 | 'tuple(address rewardToken, address pool, uint256 startTime, uint256 endTime, address refundee)' 55 | 56 | beforeEach(async () => { 57 | await erc20Helper.ensureBalancesAndApprovals( 58 | lpUser0, 59 | [context.token0, context.token1], 60 | amountDesired, 61 | context.nft.address 62 | ) 63 | 64 | tokenId = await mintPosition(context.nft.connect(lpUser0), { 65 | token0: context.token0.address, 66 | token1: context.token1.address, 67 | fee: FeeAmount.MEDIUM, 68 | tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 69 | tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 70 | recipient: lpUser0.address, 71 | amount0Desired: amountDesired, 72 | amount1Desired: amountDesired, 73 | amount0Min: 0, 74 | amount1Min: 0, 75 | deadline: (await blockTimestamp()) + 1000, 76 | }) 77 | }) 78 | 79 | describe('nft#safeTransferFrom', () => { 80 | /** 81 | * We're ultimately checking these variables, so subject calls with calldata (from actor) 82 | * and returns those three objects. */ 83 | let subject: (calldata: string, actor?: Wallet) => Promise 84 | 85 | let createIncentiveResult: HelperTypes.CreateIncentive.Result 86 | 87 | async function getTokenInfo( 88 | tokenId: string, 89 | _createIncentiveResult: HelperTypes.CreateIncentive.Result = createIncentiveResult 90 | ) { 91 | const incentiveId = await helpers.getIncentiveId(_createIncentiveResult) 92 | 93 | return { 94 | deposit: await context.staker.deposits(tokenId), 95 | incentive: await context.staker.incentives(incentiveId), 96 | stake: await context.staker.stakes(tokenId, incentiveId), 97 | } 98 | } 99 | 100 | beforeEach('setup', async () => { 101 | const { startTime } = makeTimestamps(await blockTimestamp()) 102 | 103 | createIncentiveResult = await helpers.createIncentiveFlow({ 104 | rewardToken: context.rewardToken, 105 | poolAddress: context.poolObj.address, 106 | startTime, 107 | totalReward, 108 | }) 109 | 110 | await Time.setAndMine(startTime + 1) 111 | 112 | // Make sure we're starting from a clean slate 113 | const depositBefore = await context.staker.deposits(tokenId) 114 | expect(depositBefore.owner).to.eq(constants.AddressZero) 115 | expect(depositBefore.numberOfStakes).to.eq(0) 116 | 117 | subject = async (data: string, actor: Wallet = lpUser0) => { 118 | await context.nft 119 | .connect(actor) 120 | [SAFE_TRANSFER_FROM_SIGNATURE](actor.address, context.staker.address, tokenId, data, { 121 | ...maxGas, 122 | from: actor.address, 123 | }) 124 | } 125 | }) 126 | 127 | it('allows depositing without staking', async () => { 128 | // Pass empty data 129 | await subject(ethers.utils.defaultAbiCoder.encode([], [])) 130 | const { deposit, incentive, stake } = await getTokenInfo(tokenId) 131 | 132 | expect(deposit.owner).to.eq(lpUser0.address) 133 | expect(deposit.numberOfStakes).to.eq(BN('0')) 134 | expect(incentive.numberOfStakes).to.eq(BN('0')) 135 | expect(stake.secondsPerLiquidityInsideInitialX128).to.eq(BN('0')) 136 | }) 137 | 138 | it('allows depositing and staking for a single incentive', async () => { 139 | const data = ethers.utils.defaultAbiCoder.encode( 140 | [INCENTIVE_KEY_ABI], 141 | [incentiveResultToStakeAdapter(createIncentiveResult)] 142 | ) 143 | await subject(data, lpUser0) 144 | const { deposit, incentive, stake } = await getTokenInfo(tokenId) 145 | expect(deposit.owner).to.eq(lpUser0.address) 146 | expect(deposit.numberOfStakes).to.eq(BN('1')) 147 | expect(incentive.numberOfStakes).to.eq(BN('1')) 148 | expect(stake.secondsPerLiquidityInsideInitialX128).not.to.eq(BN('0')) 149 | }) 150 | 151 | it('allows depositing and staking for two incentives', async () => { 152 | const createIncentiveResult2 = await helpers.createIncentiveFlow({ 153 | rewardToken: context.rewardToken, 154 | poolAddress: context.poolObj.address, 155 | startTime: createIncentiveResult.startTime + 100, 156 | totalReward, 157 | }) 158 | 159 | await Time.setAndMine(createIncentiveResult2.startTime) 160 | 161 | const data = ethers.utils.defaultAbiCoder.encode( 162 | [`${INCENTIVE_KEY_ABI}[]`], 163 | [[createIncentiveResult, createIncentiveResult2].map(incentiveResultToStakeAdapter)] 164 | ) 165 | 166 | await subject(data) 167 | const { deposit, incentive, stake } = await getTokenInfo(tokenId) 168 | expect(deposit.owner).to.eq(lpUser0.address) 169 | expect(deposit.numberOfStakes).to.eq(BN('2')) 170 | expect(incentive.numberOfStakes).to.eq(BN('1')) 171 | expect(stake.secondsPerLiquidityInsideInitialX128).not.to.eq(BN('0')) 172 | 173 | const { incentive: incentive2, stake: stake2 } = await getTokenInfo(tokenId, createIncentiveResult2) 174 | 175 | expect(incentive2.numberOfStakes).to.eq(BN('1')) 176 | expect(stake2.secondsPerLiquidityInsideInitialX128).not.to.eq(BN('0')) 177 | }) 178 | 179 | describe('reverts when', () => { 180 | it('staking info is less than 160 bytes and greater than 0 bytes', async () => { 181 | const data = ethers.utils.defaultAbiCoder.encode( 182 | [INCENTIVE_KEY_ABI], 183 | [incentiveResultToStakeAdapter(createIncentiveResult)] 184 | ) 185 | const malformedData = data.slice(0, data.length - 2) 186 | await expect(subject(malformedData)).to.be.reverted 187 | }) 188 | 189 | it('it has an invalid pool address', async () => { 190 | const data = ethers.utils.defaultAbiCoder.encode( 191 | [INCENTIVE_KEY_ABI], 192 | [ 193 | // Make the data invalid 194 | incentiveResultToStakeAdapter({ 195 | ...createIncentiveResult, 196 | poolAddress: constants.AddressZero, 197 | }), 198 | ] 199 | ) 200 | 201 | await expect(subject(data)).to.be.reverted 202 | }) 203 | 204 | it('staking information is invalid and greater than 160 bytes', async () => { 205 | const malformedData = 206 | ethers.utils.defaultAbiCoder.encode( 207 | [INCENTIVE_KEY_ABI], 208 | [incentiveResultToStakeAdapter(createIncentiveResult)] 209 | ) + 'aaaa' 210 | 211 | await expect(subject(malformedData)).to.be.reverted 212 | }) 213 | }) 214 | }) 215 | 216 | describe('#onERC721Received', () => { 217 | const incentiveKeyAbi = 218 | 'tuple(address rewardToken, address pool, uint256 startTime, uint256 endTime, address refundee)' 219 | let tokenId: BigNumberish 220 | let data: string 221 | let timestamps: ContractParams.Timestamps 222 | 223 | beforeEach('set up position', async () => { 224 | const { rewardToken } = context 225 | timestamps = makeTimestamps((await blockTimestamp()) + 1_000) 226 | 227 | await erc20Helper.ensureBalancesAndApprovals( 228 | lpUser0, 229 | [context.token0, context.token1], 230 | amountDesired, 231 | context.nft.address 232 | ) 233 | 234 | tokenId = await mintPosition(context.nft.connect(lpUser0), { 235 | token0: context.token0.address, 236 | token1: context.token1.address, 237 | fee: FeeAmount.MEDIUM, 238 | tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 239 | tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 240 | recipient: lpUser0.address, 241 | amount0Desired: amountDesired, 242 | amount1Desired: amountDesired, 243 | amount0Min: 0, 244 | amount1Min: 0, 245 | deadline: (await blockTimestamp()) + 1000, 246 | }) 247 | 248 | const incentive = await helpers.createIncentiveFlow({ 249 | rewardToken, 250 | totalReward, 251 | poolAddress: context.poolObj.address, 252 | ...timestamps, 253 | }) 254 | 255 | const incentiveKey: ContractParams.IncentiveKey = incentiveResultToStakeAdapter(incentive) 256 | 257 | data = ethers.utils.defaultAbiCoder.encode([incentiveKeyAbi], [incentiveKey]) 258 | }) 259 | 260 | describe('on successful transfer with staking data', () => { 261 | beforeEach('set the timestamp after the start time', async () => { 262 | await Time.set(timestamps.startTime + 1) 263 | }) 264 | 265 | it('deposits the token', async () => { 266 | expect((await context.staker.deposits(tokenId)).owner).to.equal(constants.AddressZero) 267 | await context.nft 268 | .connect(lpUser0) 269 | ['safeTransferFrom(address,address,uint256)'](lpUser0.address, context.staker.address, tokenId, { 270 | ...maxGas, 271 | from: lpUser0.address, 272 | }) 273 | 274 | expect((await context.staker.deposits(tokenId)).owner).to.equal(lpUser0.address) 275 | }) 276 | 277 | it('properly stakes the deposit in the select incentive', async () => { 278 | const incentiveId = await context.testIncentiveId.compute({ 279 | rewardToken: context.rewardToken.address, 280 | pool: context.pool01, 281 | startTime: timestamps.startTime, 282 | endTime: timestamps.endTime, 283 | refundee: incentiveCreator.address, 284 | }) 285 | await Time.set(timestamps.startTime + 10) 286 | const stakeBefore = await context.staker.stakes(tokenId, incentiveId) 287 | const depositBefore = await context.staker.deposits(tokenId) 288 | await context.nft 289 | .connect(lpUser0) 290 | ['safeTransferFrom(address,address,uint256,bytes)'](lpUser0.address, context.staker.address, tokenId, data, { 291 | ...maxGas, 292 | from: lpUser0.address, 293 | }) 294 | const stakeAfter = await context.staker.stakes(tokenId, incentiveId) 295 | 296 | expect(depositBefore.numberOfStakes).to.equal(0) 297 | expect((await context.staker.deposits(tokenId)).numberOfStakes).to.equal(1) 298 | expect(stakeBefore.secondsPerLiquidityInsideInitialX128).to.equal(0) 299 | expect(stakeAfter.secondsPerLiquidityInsideInitialX128).to.be.gt(0) 300 | }) 301 | 302 | it('has gas cost', async () => { 303 | await snapshotGasCost( 304 | context.nft 305 | .connect(lpUser0) 306 | ['safeTransferFrom(address,address,uint256,bytes)']( 307 | lpUser0.address, 308 | context.staker.address, 309 | tokenId, 310 | data, 311 | { 312 | ...maxGas, 313 | from: lpUser0.address, 314 | } 315 | ) 316 | ) 317 | }) 318 | }) 319 | 320 | describe('on invalid call', async () => { 321 | it('reverts when called by contract other than uniswap v3 nonfungiblePositionManager', async () => { 322 | await expect( 323 | context.staker.connect(lpUser0).onERC721Received(incentiveCreator.address, lpUser0.address, 1, data) 324 | ).to.be.revertedWith('UniswapV3Staker::onERC721Received: not a univ3 nft') 325 | }) 326 | 327 | it('reverts when staking on invalid incentive', async () => { 328 | const invalidStakeParams = { 329 | rewardToken: context.rewardToken.address, 330 | refundee: incentiveCreator.address, 331 | pool: context.pool01, 332 | ...timestamps, 333 | startTime: 100, 334 | } 335 | 336 | let invalidData = ethers.utils.defaultAbiCoder.encode([incentiveKeyAbi], [invalidStakeParams]) 337 | 338 | await expect( 339 | context.nft 340 | .connect(lpUser0) 341 | ['safeTransferFrom(address,address,uint256,bytes)']( 342 | lpUser0.address, 343 | context.staker.address, 344 | tokenId, 345 | invalidData 346 | ) 347 | ).to.be.revertedWith('UniswapV3Staker::stakeToken: non-existent incentive') 348 | }) 349 | }) 350 | }) 351 | 352 | describe('#withdrawToken', () => { 353 | beforeEach(async () => { 354 | await context.nft 355 | .connect(lpUser0) 356 | ['safeTransferFrom(address,address,uint256)'](lpUser0.address, context.staker.address, tokenId) 357 | 358 | subject = (_tokenId, _recipient) => context.staker.connect(lpUser0).withdrawToken(_tokenId, _recipient, '0x') 359 | }) 360 | 361 | describe('works and', () => { 362 | it('emits a DepositTransferred event', async () => 363 | await expect(subject(tokenId, recipient)) 364 | .to.emit(context.staker, 'DepositTransferred') 365 | .withArgs(tokenId, recipient, constants.AddressZero)) 366 | 367 | it('transfers nft ownership', async () => { 368 | await subject(tokenId, recipient) 369 | expect(await context.nft.ownerOf(tokenId)).to.eq(recipient) 370 | }) 371 | 372 | it('prevents you from withdrawing twice', async () => { 373 | await subject(tokenId, recipient) 374 | expect(await context.nft.ownerOf(tokenId)).to.eq(recipient) 375 | await expect(subject(tokenId, recipient)).to.be.reverted 376 | }) 377 | 378 | it('deletes deposit upon withdrawal', async () => { 379 | expect((await context.staker.deposits(tokenId)).owner).to.equal(lpUser0.address) 380 | await subject(tokenId, recipient) 381 | expect((await context.staker.deposits(tokenId)).owner).to.equal(constants.AddressZero) 382 | }) 383 | 384 | it('has gas cost', async () => await snapshotGasCost(subject(tokenId, recipient))) 385 | }) 386 | 387 | describe('fails if', () => { 388 | it('you are withdrawing a token that is not yours', async () => { 389 | const notOwner = actors.traderUser1() 390 | await expect(context.staker.connect(notOwner).withdrawToken(tokenId, notOwner.address, '0x')).to.revertedWith( 391 | 'UniswapV3Staker::withdrawToken: only owner can withdraw token' 392 | ) 393 | }) 394 | 395 | it('number of stakes is not 0', async () => { 396 | const timestamps = makeTimestamps(await blockTimestamp()) 397 | const incentiveParams: HelperTypes.CreateIncentive.Args = { 398 | rewardToken: context.rewardToken, 399 | totalReward, 400 | poolAddress: context.poolObj.address, 401 | ...timestamps, 402 | } 403 | const incentive = await helpers.createIncentiveFlow(incentiveParams) 404 | await Time.setAndMine(timestamps.startTime + 1) 405 | await context.staker.connect(lpUser0).stakeToken( 406 | { 407 | ...incentive, 408 | pool: context.pool01, 409 | rewardToken: incentive.rewardToken.address, 410 | }, 411 | tokenId 412 | ) 413 | 414 | await expect(subject(tokenId, lpUser0.address)).to.revertedWith( 415 | 'UniswapV3Staker::withdrawToken: cannot withdraw token while staked' 416 | ) 417 | }) 418 | }) 419 | }) 420 | 421 | describe('#transferDeposit', () => { 422 | const lpUser1 = actors.lpUser1() 423 | beforeEach('create a deposit by lpUser0', async () => { 424 | await context.nft 425 | .connect(lpUser0) 426 | ['safeTransferFrom(address,address,uint256)'](lpUser0.address, context.staker.address, tokenId) 427 | }) 428 | 429 | it('emits a DepositTransferred event', () => 430 | expect(context.staker.connect(lpUser0).transferDeposit(tokenId, lpUser1.address)) 431 | .to.emit(context.staker, 'DepositTransferred') 432 | .withArgs(tokenId, recipient, lpUser1.address)) 433 | 434 | it('transfers nft ownership', async () => { 435 | const { owner: ownerBefore } = await context.staker.deposits(tokenId) 436 | await context.staker.connect(lpUser0).transferDeposit(tokenId, lpUser1.address) 437 | const { owner: ownerAfter } = await context.staker.deposits(tokenId) 438 | expect(ownerBefore).to.eq(lpUser0.address) 439 | expect(ownerAfter).to.eq(lpUser1.address) 440 | }) 441 | 442 | it('can only be called by the owner', async () => { 443 | await expect(context.staker.connect(lpUser1).transferDeposit(tokenId, lpUser1.address)).to.be.revertedWith( 444 | 'UniswapV3Staker::transferDeposit: can only be called by deposit owner' 445 | ) 446 | }) 447 | 448 | it('cannot be transferred to address 0', async () => { 449 | await expect(context.staker.connect(lpUser0).transferDeposit(tokenId, constants.AddressZero)).to.be.revertedWith( 450 | 'UniswapV3Staker::transferDeposit: invalid transfer recipient' 451 | ) 452 | }) 453 | 454 | it('has gas cost', () => snapshotGasCost(context.staker.connect(lpUser0).transferDeposit(tokenId, lpUser1.address))) 455 | }) 456 | }) 457 | -------------------------------------------------------------------------------- /test/unit/Incentives.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoadFixtureFunction } from '../types' 2 | import { uniswapFixture, UniswapFixtureType } from '../shared/fixtures' 3 | import { 4 | expect, 5 | getMaxTick, 6 | getMinTick, 7 | FeeAmount, 8 | TICK_SPACINGS, 9 | blockTimestamp, 10 | BN, 11 | BNe18, 12 | snapshotGasCost, 13 | ActorFixture, 14 | erc20Wrap, 15 | makeTimestamps, 16 | } from '../shared' 17 | import { createFixtureLoader, provider } from '../shared/provider' 18 | import { HelperCommands, ERC20Helper } from '../helpers' 19 | import { ContractParams } from '../../types/contractParams' 20 | import { createTimeMachine } from '../shared/time' 21 | import { HelperTypes } from '../helpers/types' 22 | 23 | let loadFixture: LoadFixtureFunction 24 | 25 | describe('unit/Incentives', async () => { 26 | const actors = new ActorFixture(provider.getWallets(), provider) 27 | const incentiveCreator = actors.incentiveCreator() 28 | const totalReward = BNe18(100) 29 | const erc20Helper = new ERC20Helper() 30 | const Time = createTimeMachine(provider) 31 | 32 | let helpers: HelperCommands 33 | let context: UniswapFixtureType 34 | let timestamps: ContractParams.Timestamps 35 | 36 | before('loader', async () => { 37 | loadFixture = createFixtureLoader(provider.getWallets(), provider) 38 | }) 39 | 40 | beforeEach('create fixture loader', async () => { 41 | context = await loadFixture(uniswapFixture) 42 | helpers = HelperCommands.fromTestContext(context, actors, provider) 43 | }) 44 | 45 | describe('#createIncentive', () => { 46 | let subject: (params: Partial) => Promise 47 | 48 | beforeEach('setup', async () => { 49 | subject = async (params: Partial = {}) => { 50 | await erc20Helper.ensureBalancesAndApprovals( 51 | incentiveCreator, 52 | params.rewardToken ? await erc20Wrap(params?.rewardToken) : context.rewardToken, 53 | totalReward, 54 | context.staker.address 55 | ) 56 | 57 | const { startTime, endTime } = makeTimestamps(await blockTimestamp()) 58 | 59 | return await context.staker.connect(incentiveCreator).createIncentive( 60 | { 61 | rewardToken: params.rewardToken || context.rewardToken.address, 62 | pool: context.pool01, 63 | startTime: params.startTime || startTime, 64 | endTime: params.endTime || endTime, 65 | refundee: params.refundee || incentiveCreator.address, 66 | }, 67 | totalReward 68 | ) 69 | } 70 | }) 71 | 72 | describe('works and', () => { 73 | it('transfers the right amount of rewardToken', async () => { 74 | const balanceBefore = await context.rewardToken.balanceOf(context.staker.address) 75 | await subject({ 76 | reward: totalReward, 77 | rewardToken: context.rewardToken.address, 78 | }) 79 | expect(await context.rewardToken.balanceOf(context.staker.address)).to.eq(balanceBefore.add(totalReward)) 80 | }) 81 | 82 | it('emits an event with valid parameters', async () => { 83 | const { startTime, endTime } = makeTimestamps(await blockTimestamp()) 84 | await expect(subject({ startTime, endTime })) 85 | .to.emit(context.staker, 'IncentiveCreated') 86 | .withArgs( 87 | context.rewardToken.address, 88 | context.pool01, 89 | startTime, 90 | endTime, 91 | incentiveCreator.address, 92 | totalReward 93 | ) 94 | }) 95 | 96 | it('creates an incentive with the correct parameters', async () => { 97 | timestamps = makeTimestamps(await blockTimestamp()) 98 | await subject(timestamps) 99 | const incentiveId = await context.testIncentiveId.compute({ 100 | rewardToken: context.rewardToken.address, 101 | pool: context.pool01, 102 | startTime: timestamps.startTime, 103 | endTime: timestamps.endTime, 104 | refundee: incentiveCreator.address, 105 | }) 106 | 107 | const incentive = await context.staker.incentives(incentiveId) 108 | expect(incentive.totalRewardUnclaimed).to.equal(totalReward) 109 | expect(incentive.totalSecondsClaimedX128).to.equal(BN(0)) 110 | }) 111 | 112 | it('adds to existing incentives', async () => { 113 | const params = makeTimestamps(await blockTimestamp()) 114 | expect(await subject(params)).to.emit(context.staker, 'IncentiveCreated') 115 | await expect(subject(params)).to.not.be.reverted 116 | const incentiveId = await context.testIncentiveId.compute({ 117 | rewardToken: context.rewardToken.address, 118 | pool: context.pool01, 119 | startTime: timestamps.startTime, 120 | endTime: timestamps.endTime, 121 | refundee: incentiveCreator.address, 122 | }) 123 | const { totalRewardUnclaimed, totalSecondsClaimedX128, numberOfStakes } = await context.staker.incentives( 124 | incentiveId 125 | ) 126 | expect(totalRewardUnclaimed).to.equal(totalReward.mul(2)) 127 | expect(totalSecondsClaimedX128).to.equal(0) 128 | expect(numberOfStakes).to.equal(0) 129 | }) 130 | 131 | it('does not override the existing numberOfStakes', async () => { 132 | const testTimestamps = makeTimestamps(await blockTimestamp()) 133 | const rewardToken = context.token0 134 | const incentiveKey = { 135 | ...testTimestamps, 136 | rewardToken: rewardToken.address, 137 | refundee: incentiveCreator.address, 138 | pool: context.pool01, 139 | } 140 | await erc20Helper.ensureBalancesAndApprovals(actors.lpUser0(), rewardToken, BN(100), context.staker.address) 141 | await context.staker.connect(actors.lpUser0()).createIncentive(incentiveKey, 100) 142 | const incentiveId = await context.testIncentiveId.compute(incentiveKey) 143 | let { totalRewardUnclaimed, totalSecondsClaimedX128, numberOfStakes } = await context.staker.incentives( 144 | incentiveId 145 | ) 146 | expect(totalRewardUnclaimed).to.equal(100) 147 | expect(totalSecondsClaimedX128).to.equal(0) 148 | expect(numberOfStakes).to.equal(0) 149 | expect(await rewardToken.balanceOf(context.staker.address)).to.eq(100) 150 | const { tokenId } = await helpers.mintFlow({ 151 | lp: actors.lpUser0(), 152 | tokens: [context.token0, context.token1], 153 | }) 154 | await helpers.depositFlow({ 155 | lp: actors.lpUser0(), 156 | tokenId, 157 | }) 158 | 159 | await erc20Helper.ensureBalancesAndApprovals(actors.lpUser0(), rewardToken, BN(50), context.staker.address) 160 | 161 | await Time.set(testTimestamps.startTime) 162 | await context.staker 163 | .connect(actors.lpUser0()) 164 | .multicall([ 165 | context.staker.interface.encodeFunctionData('createIncentive', [incentiveKey, 50]), 166 | context.staker.interface.encodeFunctionData('stakeToken', [incentiveKey, tokenId]), 167 | ]) 168 | ;({ totalRewardUnclaimed, totalSecondsClaimedX128, numberOfStakes } = await context.staker 169 | .connect(actors.lpUser0()) 170 | .incentives(incentiveId)) 171 | expect(totalRewardUnclaimed).to.equal(150) 172 | expect(totalSecondsClaimedX128).to.equal(0) 173 | expect(numberOfStakes).to.equal(1) 174 | }) 175 | 176 | it('has gas cost', async () => { 177 | await snapshotGasCost(subject({})) 178 | }) 179 | }) 180 | 181 | describe('fails when', () => { 182 | it('is initialized with a non-contract token', async () => { 183 | const { startTime, endTime } = makeTimestamps(await blockTimestamp()) 184 | await expect( 185 | context.staker.connect(incentiveCreator).createIncentive( 186 | { 187 | rewardToken: `0x${'badadd2e55'.repeat(4)}`, 188 | pool: context.pool01, 189 | startTime, 190 | endTime, 191 | refundee: incentiveCreator.address, 192 | }, 193 | totalReward 194 | ) 195 | ).to.be.revertedWith('TransferHelperExtended::safeTransferFrom: call to non-contract') 196 | }) 197 | 198 | describe('invalid timestamps', () => { 199 | it('current time is after start time', async () => { 200 | const params = makeTimestamps(await blockTimestamp(), 100_000) 201 | 202 | // Go to after the start time 203 | await Time.setAndMine(params.startTime + 100) 204 | 205 | const now = await blockTimestamp() 206 | expect(now).to.be.greaterThan(params.startTime, 'test setup: before start time') 207 | 208 | expect(now).to.be.lessThan(params.endTime, 'test setup: after end time') 209 | 210 | await expect(subject(params)).to.be.revertedWith( 211 | 'UniswapV3Staker::createIncentive: start time must be now or in the future' 212 | ) 213 | }) 214 | 215 | it('end time is before start time', async () => { 216 | const params = makeTimestamps(await blockTimestamp()) 217 | params.endTime = params.startTime - 10 218 | await expect(subject(params)).to.be.revertedWith( 219 | 'UniswapV3Staker::createIncentive: start time must be before end time' 220 | ) 221 | }) 222 | 223 | it('start time is too far into the future', async () => { 224 | const params = makeTimestamps((await blockTimestamp()) + 2 ** 32 + 1) 225 | await expect(subject(params)).to.be.revertedWith( 226 | 'UniswapV3Staker::createIncentive: start time too far into future' 227 | ) 228 | }) 229 | 230 | it('end time is within valid duration of start time', async () => { 231 | const params = makeTimestamps(await blockTimestamp()) 232 | params.endTime = params.startTime + 2 ** 32 + 1 233 | await expect(subject(params)).to.be.revertedWith( 234 | 'UniswapV3Staker::createIncentive: incentive duration is too long' 235 | ) 236 | }) 237 | }) 238 | 239 | describe('invalid reward', () => { 240 | it('totalReward is 0 or an invalid amount', async () => { 241 | const now = await blockTimestamp() 242 | 243 | await expect( 244 | context.staker.connect(incentiveCreator).createIncentive( 245 | { 246 | rewardToken: context.rewardToken.address, 247 | pool: context.pool01, 248 | refundee: incentiveCreator.address, 249 | ...makeTimestamps(now, 1_000), 250 | }, 251 | BNe18(0) 252 | ) 253 | ).to.be.revertedWith('UniswapV3Staker::createIncentive: reward must be positive') 254 | }) 255 | }) 256 | }) 257 | }) 258 | 259 | describe('#endIncentive', () => { 260 | let subject: (params: Partial) => Promise 261 | let createIncentiveResult: HelperTypes.CreateIncentive.Result 262 | 263 | beforeEach('setup', async () => { 264 | timestamps = makeTimestamps(await blockTimestamp()) 265 | 266 | createIncentiveResult = await helpers.createIncentiveFlow({ 267 | ...timestamps, 268 | rewardToken: context.rewardToken, 269 | poolAddress: context.poolObj.address, 270 | totalReward, 271 | }) 272 | 273 | subject = async (params: Partial = {}) => { 274 | return await context.staker.connect(incentiveCreator).endIncentive({ 275 | rewardToken: params.rewardToken || context.rewardToken.address, 276 | pool: context.pool01, 277 | startTime: params.startTime || timestamps.startTime, 278 | endTime: params.endTime || timestamps.endTime, 279 | refundee: incentiveCreator.address, 280 | }) 281 | } 282 | }) 283 | 284 | describe('works and', () => { 285 | it('emits IncentiveEnded event', async () => { 286 | await Time.set(timestamps.endTime + 10) 287 | 288 | const incentiveId = await helpers.getIncentiveId(createIncentiveResult) 289 | 290 | await expect(subject({})) 291 | .to.emit(context.staker, 'IncentiveEnded') 292 | .withArgs(incentiveId, '100000000000000000000') 293 | }) 294 | 295 | it('deletes incentives[key]', async () => { 296 | const incentiveId = await helpers.getIncentiveId(createIncentiveResult) 297 | expect((await context.staker.incentives(incentiveId)).totalRewardUnclaimed).to.be.gt(0) 298 | 299 | await Time.set(timestamps.endTime + 1) 300 | await subject({}) 301 | const { totalRewardUnclaimed, totalSecondsClaimedX128, numberOfStakes } = await context.staker.incentives( 302 | incentiveId 303 | ) 304 | expect(totalRewardUnclaimed).to.eq(0) 305 | expect(totalSecondsClaimedX128).to.eq(0) 306 | expect(numberOfStakes).to.eq(0) 307 | }) 308 | 309 | it('has gas cost', async () => { 310 | await Time.set(timestamps.endTime + 1) 311 | await snapshotGasCost(subject({})) 312 | }) 313 | }) 314 | 315 | describe('reverts when', async () => { 316 | it('block.timestamp <= end time', async () => { 317 | await Time.set(timestamps.endTime - 10) 318 | await expect(subject({})).to.be.revertedWith( 319 | 'UniswapV3Staker::endIncentive: cannot end incentive before end time' 320 | ) 321 | }) 322 | 323 | it('incentive does not exist', async () => { 324 | // Adjust the block.timestamp so it is after the claim deadline 325 | await Time.set(timestamps.endTime + 1) 326 | await expect( 327 | subject({ 328 | startTime: (await blockTimestamp()) + 1000, 329 | }) 330 | ).to.be.revertedWith('UniswapV3Staker::endIncentive: no refund available') 331 | }) 332 | 333 | it('incentive has stakes', async () => { 334 | await Time.set(timestamps.startTime) 335 | const amountDesired = BNe18(10) 336 | // stake a token 337 | await helpers.mintDepositStakeFlow({ 338 | lp: actors.lpUser0(), 339 | createIncentiveResult, 340 | tokensToStake: [context.token0, context.token1], 341 | ticks: [getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM])], 342 | amountsToStake: [amountDesired, amountDesired], 343 | }) 344 | 345 | // Adjust the block.timestamp so it is after the claim deadline 346 | await Time.set(timestamps.endTime + 1) 347 | await expect(subject({})).to.be.revertedWith( 348 | 'UniswapV3Staker::endIncentive: cannot end incentive while deposits are staked' 349 | ) 350 | }) 351 | }) 352 | }) 353 | }) 354 | -------------------------------------------------------------------------------- /test/unit/Multicall.spec.ts: -------------------------------------------------------------------------------- 1 | import { LoadFixtureFunction } from '../types' 2 | import { uniswapFixture, mintPosition, UniswapFixtureType } from '../shared/fixtures' 3 | import { 4 | getMaxTick, 5 | getMinTick, 6 | FeeAmount, 7 | TICK_SPACINGS, 8 | blockTimestamp, 9 | BN, 10 | BNe18, 11 | snapshotGasCost, 12 | ActorFixture, 13 | makeTimestamps, 14 | maxGas, 15 | defaultTicksArray, 16 | expect, 17 | } from '../shared' 18 | import { createFixtureLoader, provider } from '../shared/provider' 19 | import { HelperCommands, ERC20Helper, incentiveResultToStakeAdapter } from '../helpers' 20 | import { createTimeMachine } from '../shared/time' 21 | import { HelperTypes } from '../helpers/types' 22 | 23 | let loadFixture: LoadFixtureFunction 24 | 25 | describe('unit/Multicall', () => { 26 | const actors = new ActorFixture(provider.getWallets(), provider) 27 | const incentiveCreator = actors.incentiveCreator() 28 | const lpUser0 = actors.lpUser0() 29 | const amountDesired = BNe18(10) 30 | const totalReward = BNe18(100) 31 | const erc20Helper = new ERC20Helper() 32 | const Time = createTimeMachine(provider) 33 | let helpers: HelperCommands 34 | let context: UniswapFixtureType 35 | const multicaller = actors.traderUser2() 36 | 37 | before('loader', async () => { 38 | loadFixture = createFixtureLoader(provider.getWallets(), provider) 39 | }) 40 | 41 | beforeEach('create fixture loader', async () => { 42 | context = await loadFixture(uniswapFixture) 43 | helpers = HelperCommands.fromTestContext(context, actors, provider) 44 | }) 45 | 46 | it('is implemented', async () => { 47 | const currentTime = await blockTimestamp() 48 | 49 | await erc20Helper.ensureBalancesAndApprovals( 50 | multicaller, 51 | [context.token0, context.token1], 52 | amountDesired, 53 | context.nft.address 54 | ) 55 | await mintPosition(context.nft.connect(multicaller), { 56 | token0: context.token0.address, 57 | token1: context.token1.address, 58 | fee: FeeAmount.MEDIUM, 59 | tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 60 | tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]), 61 | recipient: multicaller.address, 62 | amount0Desired: amountDesired, 63 | amount1Desired: amountDesired, 64 | amount0Min: 0, 65 | amount1Min: 0, 66 | deadline: currentTime + 10_000, 67 | }) 68 | 69 | await erc20Helper.ensureBalancesAndApprovals(multicaller, context.rewardToken, totalReward, context.staker.address) 70 | 71 | const createIncentiveTx = context.staker.interface.encodeFunctionData('createIncentive', [ 72 | { 73 | pool: context.pool01, 74 | rewardToken: context.rewardToken.address, 75 | refundee: incentiveCreator.address, 76 | ...makeTimestamps(currentTime + 100), 77 | }, 78 | totalReward, 79 | ]) 80 | await context.staker.connect(multicaller).multicall([createIncentiveTx], maxGas) 81 | 82 | // expect((await context.staker.deposits(tokenId)).owner).to.eq( 83 | // multicaller.address 84 | // ) 85 | }) 86 | 87 | it('can be used to stake an already deposited token for multiple incentives', async () => { 88 | const timestamp = await blockTimestamp() 89 | 90 | const { tokenId } = await helpers.mintFlow({ 91 | lp: multicaller, 92 | tokens: [context.token0, context.token1], 93 | }) 94 | 95 | await helpers.depositFlow({ lp: multicaller, tokenId }) 96 | 97 | // Create three incentives 98 | const incentiveParams: HelperTypes.CreateIncentive.Args = { 99 | rewardToken: context.rewardToken, 100 | poolAddress: context.poolObj.address, 101 | totalReward, 102 | ...makeTimestamps(timestamp + 100), 103 | } 104 | 105 | const incentive0 = await helpers.createIncentiveFlow(incentiveParams) 106 | 107 | const incentive1 = await helpers.createIncentiveFlow({ 108 | ...incentiveParams, 109 | startTime: incentive0.startTime + 1, 110 | }) 111 | const incentive2 = await helpers.createIncentiveFlow({ 112 | ...incentiveParams, 113 | startTime: incentive0.startTime + 2, 114 | }) 115 | 116 | await Time.setAndMine(incentive2.startTime) 117 | 118 | const tx = await context.staker 119 | .connect(multicaller) 120 | .multicall([ 121 | context.staker.interface.encodeFunctionData('stakeToken', [incentiveResultToStakeAdapter(incentive0), tokenId]), 122 | context.staker.interface.encodeFunctionData('stakeToken', [incentiveResultToStakeAdapter(incentive1), tokenId]), 123 | context.staker.interface.encodeFunctionData('stakeToken', [incentiveResultToStakeAdapter(incentive2), tokenId]), 124 | ]) 125 | 126 | await snapshotGasCost(tx) 127 | }) 128 | 129 | it('can be used to exit a position from multiple incentives', async () => { 130 | const { startTime, endTime } = makeTimestamps(await blockTimestamp(), 1000) 131 | const incentive0 = await helpers.createIncentiveFlow({ 132 | rewardToken: context.token0, 133 | startTime, 134 | endTime, 135 | refundee: actors.incentiveCreator().address, 136 | totalReward: BN(10000), 137 | poolAddress: context.pool01, 138 | }) 139 | await helpers.getIncentiveId(incentive0) 140 | const incentive1 = await helpers.createIncentiveFlow({ 141 | rewardToken: context.token1, 142 | startTime, 143 | endTime, 144 | refundee: actors.incentiveCreator().address, 145 | totalReward: BN(10000), 146 | poolAddress: context.pool01, 147 | }) 148 | await helpers.getIncentiveId(incentive1) 149 | 150 | await Time.set(startTime) 151 | 152 | const { tokenId } = await helpers.mintDepositStakeFlow({ 153 | lp: lpUser0, 154 | tokensToStake: [context.token0, context.token1], 155 | amountsToStake: [amountDesired, amountDesired], 156 | ticks: [getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]), getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM])], 157 | createIncentiveResult: incentive0, 158 | }) 159 | await context.staker.connect(lpUser0).stakeToken(incentiveResultToStakeAdapter(incentive1), tokenId) 160 | 161 | await Time.set(endTime) 162 | 163 | const tx = context.staker 164 | .connect(lpUser0) 165 | .multicall([ 166 | context.staker.interface.encodeFunctionData('unstakeToken', [ 167 | incentiveResultToStakeAdapter(incentive0), 168 | tokenId, 169 | ]), 170 | context.staker.interface.encodeFunctionData('unstakeToken', [ 171 | incentiveResultToStakeAdapter(incentive1), 172 | tokenId, 173 | ]), 174 | context.staker.interface.encodeFunctionData('withdrawToken', [tokenId, lpUser0.address, '0x']), 175 | context.staker.interface.encodeFunctionData('claimReward', [context.token0.address, lpUser0.address, BN('0')]), 176 | context.staker.interface.encodeFunctionData('claimReward', [context.token1.address, lpUser0.address, BN('0')]), 177 | ]) 178 | await snapshotGasCost(tx) 179 | }) 180 | 181 | it('can be used to exit multiple tokens from one incentive', async () => { 182 | const timestamp = await blockTimestamp() 183 | 184 | const incentive = await helpers.createIncentiveFlow({ 185 | rewardToken: context.rewardToken, 186 | poolAddress: context.poolObj.address, 187 | totalReward, 188 | ...makeTimestamps(timestamp + 100), 189 | }) 190 | 191 | const params: HelperTypes.MintDepositStake.Args = { 192 | lp: multicaller, 193 | tokensToStake: [context.token0, context.token1], 194 | amountsToStake: [amountDesired, amountDesired], 195 | ticks: defaultTicksArray(), 196 | createIncentiveResult: incentive, 197 | } 198 | 199 | await Time.setAndMine(incentive.startTime + 1) 200 | 201 | const { tokenId: tokenId0 } = await helpers.mintDepositStakeFlow(params) 202 | const { tokenId: tokenId1 } = await helpers.mintDepositStakeFlow(params) 203 | const { tokenId: tokenId2 } = await helpers.mintDepositStakeFlow(params) 204 | 205 | const unstake = (tokenId) => 206 | context.staker.interface.encodeFunctionData('unstakeToken', [incentiveResultToStakeAdapter(incentive), tokenId]) 207 | 208 | await context.staker.connect(multicaller).multicall([unstake(tokenId0), unstake(tokenId1), unstake(tokenId2)]) 209 | 210 | const { numberOfStakes: n0 } = await context.staker.deposits(tokenId0) 211 | expect(n0).to.eq(BN('0')) 212 | const { numberOfStakes: n1 } = await context.staker.deposits(tokenId1) 213 | expect(n1).to.eq(BN('0')) 214 | const { numberOfStakes: n2 } = await context.staker.deposits(tokenId2) 215 | expect(n2).to.eq(BN('0')) 216 | }) 217 | }) 218 | -------------------------------------------------------------------------------- /test/unit/RewardMath/RewardMath.spec.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'ethers' 2 | import { ethers } from 'hardhat' 3 | import { TestRewardMath } from '../../../typechain' 4 | import { expect } from '../../shared' 5 | 6 | describe('unit/RewardMath', () => { 7 | let rewardMath: TestRewardMath 8 | 9 | before('setup test reward math', async () => { 10 | const factory = await ethers.getContractFactory('TestRewardMath') 11 | rewardMath = (await factory.deploy()) as TestRewardMath 12 | }) 13 | 14 | it('half the liquidity over 20% of the total duration', async () => { 15 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 16 | /*totalRewardUnclaimed=*/ 1000, 17 | /*totalSecondsClaimedX128=*/ 0, 18 | /*startTime=*/ 100, 19 | /*endTime=*/ 200, 20 | /*liquidity=*/ 5, 21 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 22 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(20).shl(128).div(10), 23 | /*currentTime=*/ 120 24 | ) 25 | // 1000 * 0.5 * 0.2 26 | expect(reward).to.eq(100) 27 | // 20 seconds * 0.5 shl 128 28 | expect(secondsInsideX128).to.eq(BigNumber.from(10).shl(128)) 29 | }) 30 | 31 | it('all the liquidity for the duration and none of the liquidity after the end time for a whole duration', async () => { 32 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 33 | /*totalRewardUnclaimed=*/ 1000, 34 | /*totalSecondsClaimedX128=*/ 0, 35 | /*startTime=*/ 100, 36 | /*endTime=*/ 200, 37 | /*liquidity=*/ 100, 38 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 39 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(100).shl(128).div(100), 40 | /*currentTime=*/ 300 41 | ) 42 | // half the reward goes to the staker, the other half goes to those staking after the period 43 | expect(reward).to.eq(500) 44 | expect(secondsInsideX128).to.eq(BigNumber.from(100).shl(128)) 45 | }) 46 | 47 | it('all the liquidity for the duration and none of the liquidity after the end time for one second', async () => { 48 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 49 | /*totalRewardUnclaimed=*/ 1000, 50 | /*totalSecondsClaimedX128=*/ 0, 51 | /*startTime=*/ 100, 52 | /*endTime=*/ 200, 53 | /*liquidity=*/ 100, 54 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 55 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(100).shl(128).div(100), 56 | /*currentTime=*/ 201 57 | ) 58 | // the reward decays by up to the reward rate per second 59 | expect(reward).to.eq(990) 60 | expect(secondsInsideX128).to.eq(BigNumber.from(100).shl(128)) 61 | }) 62 | 63 | it('if some time is already claimed the reward is greater', async () => { 64 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 65 | /*totalRewardUnclaimed=*/ 1000, 66 | /*totalSecondsClaimedX128=*/ BigNumber.from(10).shl(128), 67 | /*startTime=*/ 100, 68 | /*endTime=*/ 200, 69 | /*liquidity=*/ 5, 70 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 71 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(20).shl(128).div(10), 72 | /*currentTime=*/ 120 73 | ) 74 | expect(reward).to.eq(111) 75 | expect(secondsInsideX128).to.eq(BigNumber.from(10).shl(128)) 76 | }) 77 | 78 | it('0 rewards left gets 0 reward', async () => { 79 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 80 | /*totalRewardUnclaimed=*/ 0, 81 | /*totalSecondsClaimedX128=*/ 0, 82 | /*startTime=*/ 100, 83 | /*endTime=*/ 200, 84 | /*liquidity=*/ 5, 85 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 86 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(20).shl(128).div(10), 87 | /*currentTime=*/ 120 88 | ) 89 | expect(reward).to.eq(0) 90 | expect(secondsInsideX128).to.eq(BigNumber.from(10).shl(128)) 91 | }) 92 | 93 | it('0 difference in seconds inside gets 0 reward', async () => { 94 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 95 | /*totalRewardUnclaimed=*/ 1000, 96 | /*totalSecondsClaimedX128=*/ 0, 97 | /*startTime=*/ 100, 98 | /*endTime=*/ 200, 99 | /*liquidity=*/ 5, 100 | /*secondsPerLiquidityInsideInitialX128=*/ BigNumber.from(20).shl(128).div(10), 101 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(20).shl(128).div(10), 102 | /*currentTime=*/ 120 103 | ) 104 | expect(reward).to.eq(0) 105 | expect(secondsInsideX128).to.eq(0) 106 | }) 107 | 108 | it('0 liquidity gets 0 reward', async () => { 109 | const { reward, secondsInsideX128 } = await rewardMath.computeRewardAmount( 110 | /*totalRewardUnclaimed=*/ 1000, 111 | /*totalSecondsClaimedX128=*/ 0, 112 | /*startTime=*/ 100, 113 | /*endTime=*/ 200, 114 | /*liquidity=*/ 0, 115 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 116 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(20).shl(128).div(10), 117 | /*currentTime=*/ 120 118 | ) 119 | expect(reward).to.eq(0) 120 | expect(secondsInsideX128).to.eq(0) 121 | }) 122 | 123 | it('throws if current time is before start time', async () => { 124 | await expect( 125 | rewardMath.computeRewardAmount( 126 | /*totalRewardUnclaimed=*/ 1000, 127 | /*totalSecondsClaimedX128=*/ 0, 128 | /*startTime=*/ 100, 129 | /*endTime=*/ 200, 130 | /*liquidity=*/ 5, 131 | /*secondsPerLiquidityInsideInitialX128=*/ 0, 132 | /*secondsPerLiquidityInsideX128=*/ BigNumber.from(20).shl(128).div(10), 133 | /*currentTime=*/ 99 134 | ) 135 | ).to.be.reverted 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/Deposits.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`unit/Deposits #onERC721Received on successful transfer with staking data has gas cost 1`] = `215336`; 4 | 5 | exports[`unit/Deposits #transferDeposit has gas cost 1`] = `29200`; 6 | 7 | exports[`unit/Deposits #withdrawToken works and has gas cost 1`] = `75943`; 8 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/Incentives.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`unit/Incentives #createIncentive works and has gas cost 1`] = `57710`; 4 | 5 | exports[`unit/Incentives #endIncentive works and has gas cost 1`] = `35724`; 6 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/Multicall.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`unit/Multicall can be used to exit a position from multiple incentives 1`] = `196836`; 4 | 5 | exports[`unit/Multicall can be used to stake an already deposited token for multiple incentives 1`] = `241964`; 6 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/Stakes.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`unit/Stakes #claimReward when requesting the full amount has gas cost 1`] = `47665`; 4 | 5 | exports[`unit/Stakes #stakeToken works and has gas cost 1`] = `115402`; 6 | 7 | exports[`unit/Stakes #unstakeToken works and has gas cost 1`] = `71345`; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "outDir": "dist", 9 | "typeRoots": ["./typechain", "./node_modules/@types", "./types"], 10 | "types": ["@nomiclabs/hardhat-ethers", "@nomiclabs/hardhat-waffle"], 11 | "noImplicitAny": false 12 | }, 13 | "include": ["./test"], 14 | "files": ["./hardhat.config.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /types/ISwapRouter.d.ts: -------------------------------------------------------------------------------- 1 | /* Autogenerated file. Do not edit manually. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import { ethers, EventFilter, Signer, BigNumber, BigNumberish, PopulatedTransaction } from 'ethers' 6 | import { Contract, ContractTransaction, Overrides, PayableOverrides, CallOverrides } from '@ethersproject/contracts' 7 | import { BytesLike } from '@ethersproject/bytes' 8 | import { Listener, Provider } from '@ethersproject/providers' 9 | import { FunctionFragment, EventFragment, Result } from '@ethersproject/abi' 10 | 11 | interface ISwapRouterInterface extends ethers.utils.Interface { 12 | functions: { 13 | 'exactInput(tuple)': FunctionFragment 14 | 'exactInputSingle(tuple)': FunctionFragment 15 | 'exactOutput(tuple)': FunctionFragment 16 | 'exactOutputSingle(tuple)': FunctionFragment 17 | 'uniswapV3SwapCallback(int256,int256,bytes)': FunctionFragment 18 | } 19 | 20 | encodeFunctionData( 21 | functionFragment: 'exactInput', 22 | values: [ 23 | { 24 | path: BytesLike 25 | recipient: string 26 | deadline: BigNumberish 27 | amountIn: BigNumberish 28 | amountOutMinimum: BigNumberish 29 | } 30 | ] 31 | ): string 32 | encodeFunctionData( 33 | functionFragment: 'exactInputSingle', 34 | values: [ 35 | { 36 | tokenIn: string 37 | tokenOut: string 38 | fee: BigNumberish 39 | recipient: string 40 | deadline: BigNumberish 41 | amountIn: BigNumberish 42 | amountOutMinimum: BigNumberish 43 | sqrtPriceLimitX96: BigNumberish 44 | } 45 | ] 46 | ): string 47 | encodeFunctionData( 48 | functionFragment: 'exactOutput', 49 | values: [ 50 | { 51 | path: BytesLike 52 | recipient: string 53 | deadline: BigNumberish 54 | amountOut: BigNumberish 55 | amountInMaximum: BigNumberish 56 | } 57 | ] 58 | ): string 59 | encodeFunctionData( 60 | functionFragment: 'exactOutputSingle', 61 | values: [ 62 | { 63 | tokenIn: string 64 | tokenOut: string 65 | fee: BigNumberish 66 | recipient: string 67 | deadline: BigNumberish 68 | amountOut: BigNumberish 69 | amountInMaximum: BigNumberish 70 | sqrtPriceLimitX96: BigNumberish 71 | } 72 | ] 73 | ): string 74 | encodeFunctionData(functionFragment: 'uniswapV3SwapCallback', values: [BigNumberish, BigNumberish, BytesLike]): string 75 | 76 | decodeFunctionResult(functionFragment: 'exactInput', data: BytesLike): Result 77 | decodeFunctionResult(functionFragment: 'exactInputSingle', data: BytesLike): Result 78 | decodeFunctionResult(functionFragment: 'exactOutput', data: BytesLike): Result 79 | decodeFunctionResult(functionFragment: 'exactOutputSingle', data: BytesLike): Result 80 | decodeFunctionResult(functionFragment: 'uniswapV3SwapCallback', data: BytesLike): Result 81 | 82 | events: {} 83 | } 84 | 85 | export class ISwapRouter extends Contract { 86 | connect(signerOrProvider: Signer | Provider | string): this 87 | attach(addressOrName: string): this 88 | deployed(): Promise 89 | 90 | on(event: EventFilter | string, listener: Listener): this 91 | once(event: EventFilter | string, listener: Listener): this 92 | addListener(eventName: EventFilter | string, listener: Listener): this 93 | removeAllListeners(eventName: EventFilter | string): this 94 | removeListener(eventName: any, listener: Listener): this 95 | 96 | interface: ISwapRouterInterface 97 | 98 | functions: { 99 | exactInput( 100 | params: { 101 | path: BytesLike 102 | recipient: string 103 | deadline: BigNumberish 104 | amountIn: BigNumberish 105 | amountOutMinimum: BigNumberish 106 | }, 107 | overrides?: PayableOverrides 108 | ): Promise 109 | 110 | 'exactInput(tuple)'( 111 | params: { 112 | path: BytesLike 113 | recipient: string 114 | deadline: BigNumberish 115 | amountIn: BigNumberish 116 | amountOutMinimum: BigNumberish 117 | }, 118 | overrides?: PayableOverrides 119 | ): Promise 120 | 121 | exactInputSingle( 122 | params: { 123 | tokenIn: string 124 | tokenOut: string 125 | fee: BigNumberish 126 | recipient: string 127 | deadline: BigNumberish 128 | amountIn: BigNumberish 129 | amountOutMinimum: BigNumberish 130 | sqrtPriceLimitX96: BigNumberish 131 | }, 132 | overrides?: PayableOverrides 133 | ): Promise 134 | 135 | 'exactInputSingle(tuple)'( 136 | params: { 137 | tokenIn: string 138 | tokenOut: string 139 | fee: BigNumberish 140 | recipient: string 141 | deadline: BigNumberish 142 | amountIn: BigNumberish 143 | amountOutMinimum: BigNumberish 144 | sqrtPriceLimitX96: BigNumberish 145 | }, 146 | overrides?: PayableOverrides 147 | ): Promise 148 | 149 | exactOutput( 150 | params: { 151 | path: BytesLike 152 | recipient: string 153 | deadline: BigNumberish 154 | amountOut: BigNumberish 155 | amountInMaximum: BigNumberish 156 | }, 157 | overrides?: PayableOverrides 158 | ): Promise 159 | 160 | 'exactOutput(tuple)'( 161 | params: { 162 | path: BytesLike 163 | recipient: string 164 | deadline: BigNumberish 165 | amountOut: BigNumberish 166 | amountInMaximum: BigNumberish 167 | }, 168 | overrides?: PayableOverrides 169 | ): Promise 170 | 171 | exactOutputSingle( 172 | params: { 173 | tokenIn: string 174 | tokenOut: string 175 | fee: BigNumberish 176 | recipient: string 177 | deadline: BigNumberish 178 | amountOut: BigNumberish 179 | amountInMaximum: BigNumberish 180 | sqrtPriceLimitX96: BigNumberish 181 | }, 182 | overrides?: PayableOverrides 183 | ): Promise 184 | 185 | 'exactOutputSingle(tuple)'( 186 | params: { 187 | tokenIn: string 188 | tokenOut: string 189 | fee: BigNumberish 190 | recipient: string 191 | deadline: BigNumberish 192 | amountOut: BigNumberish 193 | amountInMaximum: BigNumberish 194 | sqrtPriceLimitX96: BigNumberish 195 | }, 196 | overrides?: PayableOverrides 197 | ): Promise 198 | 199 | uniswapV3SwapCallback( 200 | amount0Delta: BigNumberish, 201 | amount1Delta: BigNumberish, 202 | data: BytesLike, 203 | overrides?: Overrides 204 | ): Promise 205 | 206 | 'uniswapV3SwapCallback(int256,int256,bytes)'( 207 | amount0Delta: BigNumberish, 208 | amount1Delta: BigNumberish, 209 | data: BytesLike, 210 | overrides?: Overrides 211 | ): Promise 212 | } 213 | 214 | exactInput( 215 | params: { 216 | path: BytesLike 217 | recipient: string 218 | deadline: BigNumberish 219 | amountIn: BigNumberish 220 | amountOutMinimum: BigNumberish 221 | }, 222 | overrides?: PayableOverrides 223 | ): Promise 224 | 225 | 'exactInput(tuple)'( 226 | params: { 227 | path: BytesLike 228 | recipient: string 229 | deadline: BigNumberish 230 | amountIn: BigNumberish 231 | amountOutMinimum: BigNumberish 232 | }, 233 | overrides?: PayableOverrides 234 | ): Promise 235 | 236 | exactInputSingle( 237 | params: { 238 | tokenIn: string 239 | tokenOut: string 240 | fee: BigNumberish 241 | recipient: string 242 | deadline: BigNumberish 243 | amountIn: BigNumberish 244 | amountOutMinimum: BigNumberish 245 | sqrtPriceLimitX96: BigNumberish 246 | }, 247 | overrides?: PayableOverrides 248 | ): Promise 249 | 250 | 'exactInputSingle(tuple)'( 251 | params: { 252 | tokenIn: string 253 | tokenOut: string 254 | fee: BigNumberish 255 | recipient: string 256 | deadline: BigNumberish 257 | amountIn: BigNumberish 258 | amountOutMinimum: BigNumberish 259 | sqrtPriceLimitX96: BigNumberish 260 | }, 261 | overrides?: PayableOverrides 262 | ): Promise 263 | 264 | exactOutput( 265 | params: { 266 | path: BytesLike 267 | recipient: string 268 | deadline: BigNumberish 269 | amountOut: BigNumberish 270 | amountInMaximum: BigNumberish 271 | }, 272 | overrides?: PayableOverrides 273 | ): Promise 274 | 275 | 'exactOutput(tuple)'( 276 | params: { 277 | path: BytesLike 278 | recipient: string 279 | deadline: BigNumberish 280 | amountOut: BigNumberish 281 | amountInMaximum: BigNumberish 282 | }, 283 | overrides?: PayableOverrides 284 | ): Promise 285 | 286 | exactOutputSingle( 287 | params: { 288 | tokenIn: string 289 | tokenOut: string 290 | fee: BigNumberish 291 | recipient: string 292 | deadline: BigNumberish 293 | amountOut: BigNumberish 294 | amountInMaximum: BigNumberish 295 | sqrtPriceLimitX96: BigNumberish 296 | }, 297 | overrides?: PayableOverrides 298 | ): Promise 299 | 300 | 'exactOutputSingle(tuple)'( 301 | params: { 302 | tokenIn: string 303 | tokenOut: string 304 | fee: BigNumberish 305 | recipient: string 306 | deadline: BigNumberish 307 | amountOut: BigNumberish 308 | amountInMaximum: BigNumberish 309 | sqrtPriceLimitX96: BigNumberish 310 | }, 311 | overrides?: PayableOverrides 312 | ): Promise 313 | 314 | uniswapV3SwapCallback( 315 | amount0Delta: BigNumberish, 316 | amount1Delta: BigNumberish, 317 | data: BytesLike, 318 | overrides?: Overrides 319 | ): Promise 320 | 321 | 'uniswapV3SwapCallback(int256,int256,bytes)'( 322 | amount0Delta: BigNumberish, 323 | amount1Delta: BigNumberish, 324 | data: BytesLike, 325 | overrides?: Overrides 326 | ): Promise 327 | 328 | callStatic: { 329 | exactInput( 330 | params: { 331 | path: BytesLike 332 | recipient: string 333 | deadline: BigNumberish 334 | amountIn: BigNumberish 335 | amountOutMinimum: BigNumberish 336 | }, 337 | overrides?: CallOverrides 338 | ): Promise 339 | 340 | 'exactInput(tuple)'( 341 | params: { 342 | path: BytesLike 343 | recipient: string 344 | deadline: BigNumberish 345 | amountIn: BigNumberish 346 | amountOutMinimum: BigNumberish 347 | }, 348 | overrides?: CallOverrides 349 | ): Promise 350 | 351 | exactInputSingle( 352 | params: { 353 | tokenIn: string 354 | tokenOut: string 355 | fee: BigNumberish 356 | recipient: string 357 | deadline: BigNumberish 358 | amountIn: BigNumberish 359 | amountOutMinimum: BigNumberish 360 | sqrtPriceLimitX96: BigNumberish 361 | }, 362 | overrides?: CallOverrides 363 | ): Promise 364 | 365 | 'exactInputSingle(tuple)'( 366 | params: { 367 | tokenIn: string 368 | tokenOut: string 369 | fee: BigNumberish 370 | recipient: string 371 | deadline: BigNumberish 372 | amountIn: BigNumberish 373 | amountOutMinimum: BigNumberish 374 | sqrtPriceLimitX96: BigNumberish 375 | }, 376 | overrides?: CallOverrides 377 | ): Promise 378 | 379 | exactOutput( 380 | params: { 381 | path: BytesLike 382 | recipient: string 383 | deadline: BigNumberish 384 | amountOut: BigNumberish 385 | amountInMaximum: BigNumberish 386 | }, 387 | overrides?: CallOverrides 388 | ): Promise 389 | 390 | 'exactOutput(tuple)'( 391 | params: { 392 | path: BytesLike 393 | recipient: string 394 | deadline: BigNumberish 395 | amountOut: BigNumberish 396 | amountInMaximum: BigNumberish 397 | }, 398 | overrides?: CallOverrides 399 | ): Promise 400 | 401 | exactOutputSingle( 402 | params: { 403 | tokenIn: string 404 | tokenOut: string 405 | fee: BigNumberish 406 | recipient: string 407 | deadline: BigNumberish 408 | amountOut: BigNumberish 409 | amountInMaximum: BigNumberish 410 | sqrtPriceLimitX96: BigNumberish 411 | }, 412 | overrides?: CallOverrides 413 | ): Promise 414 | 415 | 'exactOutputSingle(tuple)'( 416 | params: { 417 | tokenIn: string 418 | tokenOut: string 419 | fee: BigNumberish 420 | recipient: string 421 | deadline: BigNumberish 422 | amountOut: BigNumberish 423 | amountInMaximum: BigNumberish 424 | sqrtPriceLimitX96: BigNumberish 425 | }, 426 | overrides?: CallOverrides 427 | ): Promise 428 | 429 | uniswapV3SwapCallback( 430 | amount0Delta: BigNumberish, 431 | amount1Delta: BigNumberish, 432 | data: BytesLike, 433 | overrides?: CallOverrides 434 | ): Promise 435 | 436 | 'uniswapV3SwapCallback(int256,int256,bytes)'( 437 | amount0Delta: BigNumberish, 438 | amount1Delta: BigNumberish, 439 | data: BytesLike, 440 | overrides?: CallOverrides 441 | ): Promise 442 | } 443 | 444 | filters: {} 445 | 446 | estimateGas: { 447 | exactInput( 448 | params: { 449 | path: BytesLike 450 | recipient: string 451 | deadline: BigNumberish 452 | amountIn: BigNumberish 453 | amountOutMinimum: BigNumberish 454 | }, 455 | overrides?: PayableOverrides 456 | ): Promise 457 | 458 | 'exactInput(tuple)'( 459 | params: { 460 | path: BytesLike 461 | recipient: string 462 | deadline: BigNumberish 463 | amountIn: BigNumberish 464 | amountOutMinimum: BigNumberish 465 | }, 466 | overrides?: PayableOverrides 467 | ): Promise 468 | 469 | exactInputSingle( 470 | params: { 471 | tokenIn: string 472 | tokenOut: string 473 | fee: BigNumberish 474 | recipient: string 475 | deadline: BigNumberish 476 | amountIn: BigNumberish 477 | amountOutMinimum: BigNumberish 478 | sqrtPriceLimitX96: BigNumberish 479 | }, 480 | overrides?: PayableOverrides 481 | ): Promise 482 | 483 | 'exactInputSingle(tuple)'( 484 | params: { 485 | tokenIn: string 486 | tokenOut: string 487 | fee: BigNumberish 488 | recipient: string 489 | deadline: BigNumberish 490 | amountIn: BigNumberish 491 | amountOutMinimum: BigNumberish 492 | sqrtPriceLimitX96: BigNumberish 493 | }, 494 | overrides?: PayableOverrides 495 | ): Promise 496 | 497 | exactOutput( 498 | params: { 499 | path: BytesLike 500 | recipient: string 501 | deadline: BigNumberish 502 | amountOut: BigNumberish 503 | amountInMaximum: BigNumberish 504 | }, 505 | overrides?: PayableOverrides 506 | ): Promise 507 | 508 | 'exactOutput(tuple)'( 509 | params: { 510 | path: BytesLike 511 | recipient: string 512 | deadline: BigNumberish 513 | amountOut: BigNumberish 514 | amountInMaximum: BigNumberish 515 | }, 516 | overrides?: PayableOverrides 517 | ): Promise 518 | 519 | exactOutputSingle( 520 | params: { 521 | tokenIn: string 522 | tokenOut: string 523 | fee: BigNumberish 524 | recipient: string 525 | deadline: BigNumberish 526 | amountOut: BigNumberish 527 | amountInMaximum: BigNumberish 528 | sqrtPriceLimitX96: BigNumberish 529 | }, 530 | overrides?: PayableOverrides 531 | ): Promise 532 | 533 | 'exactOutputSingle(tuple)'( 534 | params: { 535 | tokenIn: string 536 | tokenOut: string 537 | fee: BigNumberish 538 | recipient: string 539 | deadline: BigNumberish 540 | amountOut: BigNumberish 541 | amountInMaximum: BigNumberish 542 | sqrtPriceLimitX96: BigNumberish 543 | }, 544 | overrides?: PayableOverrides 545 | ): Promise 546 | 547 | uniswapV3SwapCallback( 548 | amount0Delta: BigNumberish, 549 | amount1Delta: BigNumberish, 550 | data: BytesLike, 551 | overrides?: Overrides 552 | ): Promise 553 | 554 | 'uniswapV3SwapCallback(int256,int256,bytes)'( 555 | amount0Delta: BigNumberish, 556 | amount1Delta: BigNumberish, 557 | data: BytesLike, 558 | overrides?: Overrides 559 | ): Promise 560 | } 561 | 562 | populateTransaction: { 563 | exactInput( 564 | params: { 565 | path: BytesLike 566 | recipient: string 567 | deadline: BigNumberish 568 | amountIn: BigNumberish 569 | amountOutMinimum: BigNumberish 570 | }, 571 | overrides?: PayableOverrides 572 | ): Promise 573 | 574 | 'exactInput(tuple)'( 575 | params: { 576 | path: BytesLike 577 | recipient: string 578 | deadline: BigNumberish 579 | amountIn: BigNumberish 580 | amountOutMinimum: BigNumberish 581 | }, 582 | overrides?: PayableOverrides 583 | ): Promise 584 | 585 | exactInputSingle( 586 | params: { 587 | tokenIn: string 588 | tokenOut: string 589 | fee: BigNumberish 590 | recipient: string 591 | deadline: BigNumberish 592 | amountIn: BigNumberish 593 | amountOutMinimum: BigNumberish 594 | sqrtPriceLimitX96: BigNumberish 595 | }, 596 | overrides?: PayableOverrides 597 | ): Promise 598 | 599 | 'exactInputSingle(tuple)'( 600 | params: { 601 | tokenIn: string 602 | tokenOut: string 603 | fee: BigNumberish 604 | recipient: string 605 | deadline: BigNumberish 606 | amountIn: BigNumberish 607 | amountOutMinimum: BigNumberish 608 | sqrtPriceLimitX96: BigNumberish 609 | }, 610 | overrides?: PayableOverrides 611 | ): Promise 612 | 613 | exactOutput( 614 | params: { 615 | path: BytesLike 616 | recipient: string 617 | deadline: BigNumberish 618 | amountOut: BigNumberish 619 | amountInMaximum: BigNumberish 620 | }, 621 | overrides?: PayableOverrides 622 | ): Promise 623 | 624 | 'exactOutput(tuple)'( 625 | params: { 626 | path: BytesLike 627 | recipient: string 628 | deadline: BigNumberish 629 | amountOut: BigNumberish 630 | amountInMaximum: BigNumberish 631 | }, 632 | overrides?: PayableOverrides 633 | ): Promise 634 | 635 | exactOutputSingle( 636 | params: { 637 | tokenIn: string 638 | tokenOut: string 639 | fee: BigNumberish 640 | recipient: string 641 | deadline: BigNumberish 642 | amountOut: BigNumberish 643 | amountInMaximum: BigNumberish 644 | sqrtPriceLimitX96: BigNumberish 645 | }, 646 | overrides?: PayableOverrides 647 | ): Promise 648 | 649 | 'exactOutputSingle(tuple)'( 650 | params: { 651 | tokenIn: string 652 | tokenOut: string 653 | fee: BigNumberish 654 | recipient: string 655 | deadline: BigNumberish 656 | amountOut: BigNumberish 657 | amountInMaximum: BigNumberish 658 | sqrtPriceLimitX96: BigNumberish 659 | }, 660 | overrides?: PayableOverrides 661 | ): Promise 662 | 663 | uniswapV3SwapCallback( 664 | amount0Delta: BigNumberish, 665 | amount1Delta: BigNumberish, 666 | data: BytesLike, 667 | overrides?: Overrides 668 | ): Promise 669 | 670 | 'uniswapV3SwapCallback(int256,int256,bytes)'( 671 | amount0Delta: BigNumberish, 672 | amount1Delta: BigNumberish, 673 | data: BytesLike, 674 | overrides?: Overrides 675 | ): Promise 676 | } 677 | } 678 | -------------------------------------------------------------------------------- /types/IWETH9.d.ts: -------------------------------------------------------------------------------- 1 | /* Autogenerated file. Do not edit manually. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import { ethers, EventFilter, Signer, BigNumber, BigNumberish, PopulatedTransaction } from 'ethers' 6 | import { Contract, ContractTransaction, Overrides, PayableOverrides, CallOverrides } from '@ethersproject/contracts' 7 | import { BytesLike } from '@ethersproject/bytes' 8 | import { Listener, Provider } from '@ethersproject/providers' 9 | import { FunctionFragment, EventFragment, Result } from '@ethersproject/abi' 10 | 11 | interface IWETH9Interface extends ethers.utils.Interface { 12 | functions: { 13 | 'allowance(address,address)': FunctionFragment 14 | 'approve(address,uint256)': FunctionFragment 15 | 'balanceOf(address)': FunctionFragment 16 | 'deposit()': FunctionFragment 17 | 'totalSupply()': FunctionFragment 18 | 'transfer(address,uint256)': FunctionFragment 19 | 'transferFrom(address,address,uint256)': FunctionFragment 20 | 'withdraw(uint256)': FunctionFragment 21 | } 22 | 23 | encodeFunctionData(functionFragment: 'allowance', values: [string, string]): string 24 | encodeFunctionData(functionFragment: 'approve', values: [string, BigNumberish]): string 25 | encodeFunctionData(functionFragment: 'balanceOf', values: [string]): string 26 | encodeFunctionData(functionFragment: 'deposit', values?: undefined): string 27 | encodeFunctionData(functionFragment: 'totalSupply', values?: undefined): string 28 | encodeFunctionData(functionFragment: 'transfer', values: [string, BigNumberish]): string 29 | encodeFunctionData(functionFragment: 'transferFrom', values: [string, string, BigNumberish]): string 30 | encodeFunctionData(functionFragment: 'withdraw', values: [BigNumberish]): string 31 | 32 | decodeFunctionResult(functionFragment: 'allowance', data: BytesLike): Result 33 | decodeFunctionResult(functionFragment: 'approve', data: BytesLike): Result 34 | decodeFunctionResult(functionFragment: 'balanceOf', data: BytesLike): Result 35 | decodeFunctionResult(functionFragment: 'deposit', data: BytesLike): Result 36 | decodeFunctionResult(functionFragment: 'totalSupply', data: BytesLike): Result 37 | decodeFunctionResult(functionFragment: 'transfer', data: BytesLike): Result 38 | decodeFunctionResult(functionFragment: 'transferFrom', data: BytesLike): Result 39 | decodeFunctionResult(functionFragment: 'withdraw', data: BytesLike): Result 40 | 41 | events: { 42 | 'Approval(address,address,uint256)': EventFragment 43 | 'Transfer(address,address,uint256)': EventFragment 44 | } 45 | 46 | getEvent(nameOrSignatureOrTopic: 'Approval'): EventFragment 47 | getEvent(nameOrSignatureOrTopic: 'Transfer'): EventFragment 48 | } 49 | 50 | export class IWETH9 extends Contract { 51 | connect(signerOrProvider: Signer | Provider | string): this 52 | attach(addressOrName: string): this 53 | deployed(): Promise 54 | 55 | on(event: EventFilter | string, listener: Listener): this 56 | once(event: EventFilter | string, listener: Listener): this 57 | addListener(eventName: EventFilter | string, listener: Listener): this 58 | removeAllListeners(eventName: EventFilter | string): this 59 | removeListener(eventName: any, listener: Listener): this 60 | 61 | interface: IWETH9Interface 62 | 63 | functions: { 64 | allowance( 65 | owner: string, 66 | spender: string, 67 | overrides?: CallOverrides 68 | ): Promise<{ 69 | 0: BigNumber 70 | }> 71 | 72 | 'allowance(address,address)'( 73 | owner: string, 74 | spender: string, 75 | overrides?: CallOverrides 76 | ): Promise<{ 77 | 0: BigNumber 78 | }> 79 | 80 | approve(spender: string, amount: BigNumberish, overrides?: Overrides): Promise 81 | 82 | 'approve(address,uint256)'( 83 | spender: string, 84 | amount: BigNumberish, 85 | overrides?: Overrides 86 | ): Promise 87 | 88 | balanceOf( 89 | account: string, 90 | overrides?: CallOverrides 91 | ): Promise<{ 92 | 0: BigNumber 93 | }> 94 | 95 | 'balanceOf(address)'( 96 | account: string, 97 | overrides?: CallOverrides 98 | ): Promise<{ 99 | 0: BigNumber 100 | }> 101 | 102 | deposit(overrides?: PayableOverrides): Promise 103 | 104 | 'deposit()'(overrides?: PayableOverrides): Promise 105 | 106 | totalSupply( 107 | overrides?: CallOverrides 108 | ): Promise<{ 109 | 0: BigNumber 110 | }> 111 | 112 | 'totalSupply()'( 113 | overrides?: CallOverrides 114 | ): Promise<{ 115 | 0: BigNumber 116 | }> 117 | 118 | transfer(recipient: string, amount: BigNumberish, overrides?: Overrides): Promise 119 | 120 | 'transfer(address,uint256)'( 121 | recipient: string, 122 | amount: BigNumberish, 123 | overrides?: Overrides 124 | ): Promise 125 | 126 | transferFrom( 127 | sender: string, 128 | recipient: string, 129 | amount: BigNumberish, 130 | overrides?: Overrides 131 | ): Promise 132 | 133 | 'transferFrom(address,address,uint256)'( 134 | sender: string, 135 | recipient: string, 136 | amount: BigNumberish, 137 | overrides?: Overrides 138 | ): Promise 139 | 140 | withdraw(arg0: BigNumberish, overrides?: Overrides): Promise 141 | 142 | 'withdraw(uint256)'(arg0: BigNumberish, overrides?: Overrides): Promise 143 | } 144 | 145 | allowance(owner: string, spender: string, overrides?: CallOverrides): Promise 146 | 147 | 'allowance(address,address)'(owner: string, spender: string, overrides?: CallOverrides): Promise 148 | 149 | approve(spender: string, amount: BigNumberish, overrides?: Overrides): Promise 150 | 151 | 'approve(address,uint256)'(spender: string, amount: BigNumberish, overrides?: Overrides): Promise 152 | 153 | balanceOf(account: string, overrides?: CallOverrides): Promise 154 | 155 | 'balanceOf(address)'(account: string, overrides?: CallOverrides): Promise 156 | 157 | deposit(overrides?: PayableOverrides): Promise 158 | 159 | 'deposit()'(overrides?: PayableOverrides): Promise 160 | 161 | totalSupply(overrides?: CallOverrides): Promise 162 | 163 | 'totalSupply()'(overrides?: CallOverrides): Promise 164 | 165 | transfer(recipient: string, amount: BigNumberish, overrides?: Overrides): Promise 166 | 167 | 'transfer(address,uint256)'( 168 | recipient: string, 169 | amount: BigNumberish, 170 | overrides?: Overrides 171 | ): Promise 172 | 173 | transferFrom( 174 | sender: string, 175 | recipient: string, 176 | amount: BigNumberish, 177 | overrides?: Overrides 178 | ): Promise 179 | 180 | 'transferFrom(address,address,uint256)'( 181 | sender: string, 182 | recipient: string, 183 | amount: BigNumberish, 184 | overrides?: Overrides 185 | ): Promise 186 | 187 | withdraw(arg0: BigNumberish, overrides?: Overrides): Promise 188 | 189 | 'withdraw(uint256)'(arg0: BigNumberish, overrides?: Overrides): Promise 190 | 191 | callStatic: { 192 | allowance(owner: string, spender: string, overrides?: CallOverrides): Promise 193 | 194 | 'allowance(address,address)'(owner: string, spender: string, overrides?: CallOverrides): Promise 195 | 196 | approve(spender: string, amount: BigNumberish, overrides?: CallOverrides): Promise 197 | 198 | 'approve(address,uint256)'(spender: string, amount: BigNumberish, overrides?: CallOverrides): Promise 199 | 200 | balanceOf(account: string, overrides?: CallOverrides): Promise 201 | 202 | 'balanceOf(address)'(account: string, overrides?: CallOverrides): Promise 203 | 204 | deposit(overrides?: CallOverrides): Promise 205 | 206 | 'deposit()'(overrides?: CallOverrides): Promise 207 | 208 | totalSupply(overrides?: CallOverrides): Promise 209 | 210 | 'totalSupply()'(overrides?: CallOverrides): Promise 211 | 212 | transfer(recipient: string, amount: BigNumberish, overrides?: CallOverrides): Promise 213 | 214 | 'transfer(address,uint256)'(recipient: string, amount: BigNumberish, overrides?: CallOverrides): Promise 215 | 216 | transferFrom(sender: string, recipient: string, amount: BigNumberish, overrides?: CallOverrides): Promise 217 | 218 | 'transferFrom(address,address,uint256)'( 219 | sender: string, 220 | recipient: string, 221 | amount: BigNumberish, 222 | overrides?: CallOverrides 223 | ): Promise 224 | 225 | withdraw(arg0: BigNumberish, overrides?: CallOverrides): Promise 226 | 227 | 'withdraw(uint256)'(arg0: BigNumberish, overrides?: CallOverrides): Promise 228 | } 229 | 230 | filters: { 231 | Approval(owner: string | null, spender: string | null, value: null): EventFilter 232 | 233 | Transfer(from: string | null, to: string | null, value: null): EventFilter 234 | } 235 | 236 | estimateGas: { 237 | allowance(owner: string, spender: string, overrides?: CallOverrides): Promise 238 | 239 | 'allowance(address,address)'(owner: string, spender: string, overrides?: CallOverrides): Promise 240 | 241 | approve(spender: string, amount: BigNumberish, overrides?: Overrides): Promise 242 | 243 | 'approve(address,uint256)'(spender: string, amount: BigNumberish, overrides?: Overrides): Promise 244 | 245 | balanceOf(account: string, overrides?: CallOverrides): Promise 246 | 247 | 'balanceOf(address)'(account: string, overrides?: CallOverrides): Promise 248 | 249 | deposit(overrides?: PayableOverrides): Promise 250 | 251 | 'deposit()'(overrides?: PayableOverrides): Promise 252 | 253 | totalSupply(overrides?: CallOverrides): Promise 254 | 255 | 'totalSupply()'(overrides?: CallOverrides): Promise 256 | 257 | transfer(recipient: string, amount: BigNumberish, overrides?: Overrides): Promise 258 | 259 | 'transfer(address,uint256)'(recipient: string, amount: BigNumberish, overrides?: Overrides): Promise 260 | 261 | transferFrom(sender: string, recipient: string, amount: BigNumberish, overrides?: Overrides): Promise 262 | 263 | 'transferFrom(address,address,uint256)'( 264 | sender: string, 265 | recipient: string, 266 | amount: BigNumberish, 267 | overrides?: Overrides 268 | ): Promise 269 | 270 | withdraw(arg0: BigNumberish, overrides?: Overrides): Promise 271 | 272 | 'withdraw(uint256)'(arg0: BigNumberish, overrides?: Overrides): Promise 273 | } 274 | 275 | populateTransaction: { 276 | allowance(owner: string, spender: string, overrides?: CallOverrides): Promise 277 | 278 | 'allowance(address,address)'( 279 | owner: string, 280 | spender: string, 281 | overrides?: CallOverrides 282 | ): Promise 283 | 284 | approve(spender: string, amount: BigNumberish, overrides?: Overrides): Promise 285 | 286 | 'approve(address,uint256)'( 287 | spender: string, 288 | amount: BigNumberish, 289 | overrides?: Overrides 290 | ): Promise 291 | 292 | balanceOf(account: string, overrides?: CallOverrides): Promise 293 | 294 | 'balanceOf(address)'(account: string, overrides?: CallOverrides): Promise 295 | 296 | deposit(overrides?: PayableOverrides): Promise 297 | 298 | 'deposit()'(overrides?: PayableOverrides): Promise 299 | 300 | totalSupply(overrides?: CallOverrides): Promise 301 | 302 | 'totalSupply()'(overrides?: CallOverrides): Promise 303 | 304 | transfer(recipient: string, amount: BigNumberish, overrides?: Overrides): Promise 305 | 306 | 'transfer(address,uint256)'( 307 | recipient: string, 308 | amount: BigNumberish, 309 | overrides?: Overrides 310 | ): Promise 311 | 312 | transferFrom( 313 | sender: string, 314 | recipient: string, 315 | amount: BigNumberish, 316 | overrides?: Overrides 317 | ): Promise 318 | 319 | 'transferFrom(address,address,uint256)'( 320 | sender: string, 321 | recipient: string, 322 | amount: BigNumberish, 323 | overrides?: Overrides 324 | ): Promise 325 | 326 | withdraw(arg0: BigNumberish, overrides?: Overrides): Promise 327 | 328 | 'withdraw(uint256)'(arg0: BigNumberish, overrides?: Overrides): Promise 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /types/NFTDescriptor.d.ts: -------------------------------------------------------------------------------- 1 | /* Autogenerated file. Do not edit manually. */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import { ethers, EventFilter, Signer, BigNumber, BigNumberish, PopulatedTransaction } from 'ethers' 6 | import { Contract, ContractTransaction, CallOverrides } from '@ethersproject/contracts' 7 | import { BytesLike } from '@ethersproject/bytes' 8 | import { Listener, Provider } from '@ethersproject/providers' 9 | import { FunctionFragment, EventFragment, Result } from '@ethersproject/abi' 10 | 11 | interface NFTDescriptorInterface extends ethers.utils.Interface { 12 | functions: { 13 | 'constructTokenURI(tuple)': FunctionFragment 14 | } 15 | 16 | encodeFunctionData( 17 | functionFragment: 'constructTokenURI', 18 | values: [ 19 | { 20 | tokenId: BigNumberish 21 | quoteTokenAddress: string 22 | baseTokenAddress: string 23 | quoteTokenSymbol: string 24 | baseTokenSymbol: string 25 | quoteTokenDecimals: BigNumberish 26 | baseTokenDecimals: BigNumberish 27 | flipRatio: boolean 28 | tickLower: BigNumberish 29 | tickUpper: BigNumberish 30 | tickCurrent: BigNumberish 31 | tickSpacing: BigNumberish 32 | fee: BigNumberish 33 | poolAddress: string 34 | } 35 | ] 36 | ): string 37 | 38 | decodeFunctionResult(functionFragment: 'constructTokenURI', data: BytesLike): Result 39 | 40 | events: {} 41 | } 42 | 43 | export class NFTDescriptor extends Contract { 44 | connect(signerOrProvider: Signer | Provider | string): this 45 | attach(addressOrName: string): this 46 | deployed(): Promise 47 | 48 | on(event: EventFilter | string, listener: Listener): this 49 | once(event: EventFilter | string, listener: Listener): this 50 | addListener(eventName: EventFilter | string, listener: Listener): this 51 | removeAllListeners(eventName: EventFilter | string): this 52 | removeListener(eventName: any, listener: Listener): this 53 | 54 | interface: NFTDescriptorInterface 55 | 56 | functions: { 57 | constructTokenURI( 58 | params: { 59 | tokenId: BigNumberish 60 | quoteTokenAddress: string 61 | baseTokenAddress: string 62 | quoteTokenSymbol: string 63 | baseTokenSymbol: string 64 | quoteTokenDecimals: BigNumberish 65 | baseTokenDecimals: BigNumberish 66 | flipRatio: boolean 67 | tickLower: BigNumberish 68 | tickUpper: BigNumberish 69 | tickCurrent: BigNumberish 70 | tickSpacing: BigNumberish 71 | fee: BigNumberish 72 | poolAddress: string 73 | }, 74 | overrides?: CallOverrides 75 | ): Promise<{ 76 | 0: string 77 | }> 78 | 79 | 'constructTokenURI(tuple)'( 80 | params: { 81 | tokenId: BigNumberish 82 | quoteTokenAddress: string 83 | baseTokenAddress: string 84 | quoteTokenSymbol: string 85 | baseTokenSymbol: string 86 | quoteTokenDecimals: BigNumberish 87 | baseTokenDecimals: BigNumberish 88 | flipRatio: boolean 89 | tickLower: BigNumberish 90 | tickUpper: BigNumberish 91 | tickCurrent: BigNumberish 92 | tickSpacing: BigNumberish 93 | fee: BigNumberish 94 | poolAddress: string 95 | }, 96 | overrides?: CallOverrides 97 | ): Promise<{ 98 | 0: string 99 | }> 100 | } 101 | 102 | constructTokenURI( 103 | params: { 104 | tokenId: BigNumberish 105 | quoteTokenAddress: string 106 | baseTokenAddress: string 107 | quoteTokenSymbol: string 108 | baseTokenSymbol: string 109 | quoteTokenDecimals: BigNumberish 110 | baseTokenDecimals: BigNumberish 111 | flipRatio: boolean 112 | tickLower: BigNumberish 113 | tickUpper: BigNumberish 114 | tickCurrent: BigNumberish 115 | tickSpacing: BigNumberish 116 | fee: BigNumberish 117 | poolAddress: string 118 | }, 119 | overrides?: CallOverrides 120 | ): Promise 121 | 122 | 'constructTokenURI(tuple)'( 123 | params: { 124 | tokenId: BigNumberish 125 | quoteTokenAddress: string 126 | baseTokenAddress: string 127 | quoteTokenSymbol: string 128 | baseTokenSymbol: string 129 | quoteTokenDecimals: BigNumberish 130 | baseTokenDecimals: BigNumberish 131 | flipRatio: boolean 132 | tickLower: BigNumberish 133 | tickUpper: BigNumberish 134 | tickCurrent: BigNumberish 135 | tickSpacing: BigNumberish 136 | fee: BigNumberish 137 | poolAddress: string 138 | }, 139 | overrides?: CallOverrides 140 | ): Promise 141 | 142 | callStatic: { 143 | constructTokenURI( 144 | params: { 145 | tokenId: BigNumberish 146 | quoteTokenAddress: string 147 | baseTokenAddress: string 148 | quoteTokenSymbol: string 149 | baseTokenSymbol: string 150 | quoteTokenDecimals: BigNumberish 151 | baseTokenDecimals: BigNumberish 152 | flipRatio: boolean 153 | tickLower: BigNumberish 154 | tickUpper: BigNumberish 155 | tickCurrent: BigNumberish 156 | tickSpacing: BigNumberish 157 | fee: BigNumberish 158 | poolAddress: string 159 | }, 160 | overrides?: CallOverrides 161 | ): Promise 162 | 163 | 'constructTokenURI(tuple)'( 164 | params: { 165 | tokenId: BigNumberish 166 | quoteTokenAddress: string 167 | baseTokenAddress: string 168 | quoteTokenSymbol: string 169 | baseTokenSymbol: string 170 | quoteTokenDecimals: BigNumberish 171 | baseTokenDecimals: BigNumberish 172 | flipRatio: boolean 173 | tickLower: BigNumberish 174 | tickUpper: BigNumberish 175 | tickCurrent: BigNumberish 176 | tickSpacing: BigNumberish 177 | fee: BigNumberish 178 | poolAddress: string 179 | }, 180 | overrides?: CallOverrides 181 | ): Promise 182 | } 183 | 184 | filters: {} 185 | 186 | estimateGas: { 187 | constructTokenURI( 188 | params: { 189 | tokenId: BigNumberish 190 | quoteTokenAddress: string 191 | baseTokenAddress: string 192 | quoteTokenSymbol: string 193 | baseTokenSymbol: string 194 | quoteTokenDecimals: BigNumberish 195 | baseTokenDecimals: BigNumberish 196 | flipRatio: boolean 197 | tickLower: BigNumberish 198 | tickUpper: BigNumberish 199 | tickCurrent: BigNumberish 200 | tickSpacing: BigNumberish 201 | fee: BigNumberish 202 | poolAddress: string 203 | }, 204 | overrides?: CallOverrides 205 | ): Promise 206 | 207 | 'constructTokenURI(tuple)'( 208 | params: { 209 | tokenId: BigNumberish 210 | quoteTokenAddress: string 211 | baseTokenAddress: string 212 | quoteTokenSymbol: string 213 | baseTokenSymbol: string 214 | quoteTokenDecimals: BigNumberish 215 | baseTokenDecimals: BigNumberish 216 | flipRatio: boolean 217 | tickLower: BigNumberish 218 | tickUpper: BigNumberish 219 | tickCurrent: BigNumberish 220 | tickSpacing: BigNumberish 221 | fee: BigNumberish 222 | poolAddress: string 223 | }, 224 | overrides?: CallOverrides 225 | ): Promise 226 | } 227 | 228 | populateTransaction: { 229 | constructTokenURI( 230 | params: { 231 | tokenId: BigNumberish 232 | quoteTokenAddress: string 233 | baseTokenAddress: string 234 | quoteTokenSymbol: string 235 | baseTokenSymbol: string 236 | quoteTokenDecimals: BigNumberish 237 | baseTokenDecimals: BigNumberish 238 | flipRatio: boolean 239 | tickLower: BigNumberish 240 | tickUpper: BigNumberish 241 | tickCurrent: BigNumberish 242 | tickSpacing: BigNumberish 243 | fee: BigNumberish 244 | poolAddress: string 245 | }, 246 | overrides?: CallOverrides 247 | ): Promise 248 | 249 | 'constructTokenURI(tuple)'( 250 | params: { 251 | tokenId: BigNumberish 252 | quoteTokenAddress: string 253 | baseTokenAddress: string 254 | quoteTokenSymbol: string 255 | baseTokenSymbol: string 256 | quoteTokenDecimals: BigNumberish 257 | baseTokenDecimals: BigNumberish 258 | flipRatio: boolean 259 | tickLower: BigNumberish 260 | tickUpper: BigNumberish 261 | tickCurrent: BigNumberish 262 | tickSpacing: BigNumberish 263 | fee: BigNumberish 264 | poolAddress: string 265 | }, 266 | overrides?: CallOverrides 267 | ): Promise 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /types/contractParams.ts: -------------------------------------------------------------------------------- 1 | import { BigNumberish } from 'ethers' 2 | 3 | export module ContractParams { 4 | export type Timestamps = { 5 | startTime: number 6 | endTime: number 7 | } 8 | 9 | export type IncentiveKey = { 10 | pool: string 11 | rewardToken: string 12 | refundee: string 13 | } & Timestamps 14 | 15 | export type CreateIncentive = IncentiveKey & { 16 | reward: BigNumberish 17 | } 18 | 19 | export type EndIncentive = IncentiveKey 20 | } 21 | --------------------------------------------------------------------------------