Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Time-vesting the Uniswap v3 staker #235

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ This is the canonical staking contract designed for [Uniswap V3](https://github.

## Deployments

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 on all networks at the address: `0xe34139463bA50bD61336E0c446Bd8C0867c6fE65`:
v1.0.3

The main change compared to v1.0.2 is the addition of a new configuration value called vestingPeriod, which defines the minimal time a staked position needs to be in range to recieve the full reward.

| Network | Explorer |
| ---------------- | ---------------------------------------------------------------------------------------- |
| Polygon | https://polygonscan.com/address/0x8c696deF6Db3104DF72F7843730784460795659a |

v1.0.2

| Network | Explorer |
| ---------------- | ---------------------------------------------------------------------------------------- |
Expand Down
Binary file added audit/PeckShield-Audit-Report-Revert-v1.0.pdf
Binary file not shown.
95 changes: 64 additions & 31 deletions contracts/UniswapV3Staker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
/// @notice Represents a staking incentive
struct Incentive {
uint256 totalRewardUnclaimed;
uint256 totalRewardLocked;
uint160 totalSecondsClaimedX128;
uint96 numberOfStakes;
}
Expand All @@ -35,7 +36,8 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
/// @notice Represents a staked liquidity NFT
struct Stake {
uint160 secondsPerLiquidityInsideInitialX128;
uint96 liquidityNoOverflow;
uint32 secondsInsideInitial;
uint64 liquidityNoOverflow;
uint128 liquidityIfOverflow;
}

Expand Down Expand Up @@ -63,12 +65,13 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
public
view
override
returns (uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity)
returns (uint160 secondsPerLiquidityInsideInitialX128, uint32 secondsInsideInitial, uint128 liquidity)
{
Stake storage stake = _stakes[tokenId][incentiveId];
secondsPerLiquidityInsideInitialX128 = stake.secondsPerLiquidityInsideInitialX128;
secondsInsideInitial = stake.secondsInsideInitial;
liquidity = stake.liquidityNoOverflow;
if (liquidity == type(uint96).max) {
if (liquidity == type(uint64).max) {
liquidity = stake.liquidityIfOverflow;
}
}
Expand Down Expand Up @@ -110,13 +113,24 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
'UniswapV3Staker::createIncentive: incentive duration is too long'
);

require(
key.vestingPeriod <= key.endTime - key.startTime,
'UniswapV3Staker::createIncentive: vesting time must be lte incentive duration'
);

require(
key.refundee != address(0),
'UniswapV3Staker::createIncentive: refundee must be a valid address'
);


bytes32 incentiveId = IncentiveId.compute(key);

incentives[incentiveId].totalRewardUnclaimed += reward;

TransferHelperExtended.safeTransferFrom(address(key.rewardToken), msg.sender, address(this), reward);

emit IncentiveCreated(key.rewardToken, key.pool, key.startTime, key.endTime, key.refundee, reward);
emit IncentiveCreated(key.rewardToken, key.pool, key.startTime, key.endTime, key.vestingPeriod, key.refundee, reward);
}

/// @inheritdoc IUniswapV3Staker
Expand All @@ -126,7 +140,7 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
bytes32 incentiveId = IncentiveId.compute(key);
Incentive storage incentive = incentives[incentiveId];

refund = incentive.totalRewardUnclaimed;
refund = incentive.totalRewardUnclaimed + incentive.totalRewardLocked;

require(refund > 0, 'UniswapV3Staker::endIncentive: no refund available');
require(
Expand All @@ -136,6 +150,7 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {

// issue the refund
incentive.totalRewardUnclaimed = 0;
incentive.totalRewardLocked = 0;
TransferHelperExtended.safeTransfer(address(key.rewardToken), key.refundee, refund);

// note we never clear totalSecondsClaimedX128
Expand Down Expand Up @@ -163,7 +178,7 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
emit DepositTransferred(tokenId, address(0), from);

if (data.length > 0) {
if (data.length == 160) {
if (data.length == 192) {
_stakeToken(abi.decode(data, (IncentiveKey)), tokenId);
} else {
IncentiveKey[] memory keys = abi.decode(data, (IncentiveKey[]));
Expand All @@ -177,7 +192,8 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {

/// @inheritdoc IUniswapV3Staker
function transferDeposit(uint256 tokenId, address to) external override {
require(to != address(0), 'UniswapV3Staker::transferDeposit: invalid transfer recipient');
require(to != address(0), 'UniswapV3Staker::transferDeposit: invalid transfer recipient: (address 0)');
require(to != address(this), 'UniswapV3Staker::transferDeposit: invalid transfer recipient (staker address)');
address owner = deposits[tokenId].owner;
require(owner == msg.sender, 'UniswapV3Staker::transferDeposit: can only be called by deposit owner');
deposits[tokenId].owner = to;
Expand Down Expand Up @@ -221,7 +237,7 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {

bytes32 incentiveId = IncentiveId.compute(key);

(uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity) = stakes(tokenId, incentiveId);
(uint160 secondsPerLiquidityInsideInitialX128, uint32 secondsInsideInitial, uint128 liquidity) = stakes(tokenId, incentiveId);

require(liquidity != 0, 'UniswapV3Staker::unstakeToken: stake does not exist');

Expand All @@ -230,32 +246,42 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
deposits[tokenId].numberOfStakes--;
incentive.numberOfStakes--;

(, uint160 secondsPerLiquidityInsideX128, ) =
(, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) =
key.pool.snapshotCumulativesInside(deposit.tickLower, deposit.tickUpper);
(uint256 reward, uint160 secondsInsideX128) =
RewardMath.computeRewardAmount(
(uint256 reward, uint256 maxReward, uint160 secondsInsideX128) =
RewardMath.computeRewardAmount(RewardMath.ComputeRewardAmountParams(
incentive.totalRewardUnclaimed,
incentive.totalSecondsClaimedX128,
key.startTime,
key.endTime,
key.vestingPeriod,
liquidity,
secondsPerLiquidityInsideInitialX128,
secondsPerLiquidityInsideX128,
secondsInsideInitial,
secondsInside,
block.timestamp
);
));

// if this overflows, e.g. after 2^32-1 full liquidity seconds have been claimed,
// reward rate will fall drastically so it's safe
incentive.totalSecondsClaimedX128 += secondsInsideX128;
// reward is never greater than total reward unclaimed
incentive.totalRewardUnclaimed -= reward;
incentive.totalRewardUnclaimed -= maxReward;

// if not all reward is payed to owner, add difference to locked amount to be withdrawable at end of incentive
if (maxReward > reward) {
incentive.totalRewardLocked += maxReward - reward;
}

// this only overflows if a token has a total supply greater than type(uint256).max
rewards[key.rewardToken][deposit.owner] += reward;

Stake storage stake = _stakes[tokenId][incentiveId];
delete stake.secondsPerLiquidityInsideInitialX128;
delete stake.secondsInsideInitial;
delete stake.liquidityNoOverflow;
if (liquidity >= type(uint96).max) delete stake.liquidityIfOverflow;
if (liquidity >= type(uint64).max) delete stake.liquidityIfOverflow;
emit TokenUnstaked(tokenId, incentiveId);
}

Expand All @@ -273,36 +299,41 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
rewards[rewardToken][msg.sender] -= reward;
TransferHelperExtended.safeTransfer(address(rewardToken), to, reward);

emit RewardClaimed(to, reward);
emit RewardClaimed(rewardToken, to, reward);
}

/// @inheritdoc IUniswapV3Staker
function getRewardInfo(IncentiveKey memory key, uint256 tokenId)
external
view
override
returns (uint256 reward, uint160 secondsInsideX128)
returns (uint256 reward, uint256 maxReward, uint160 secondsInsideX128)
{
bytes32 incentiveId = IncentiveId.compute(key);

(uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity) = stakes(tokenId, incentiveId);
(uint160 secondsPerLiquidityInsideInitialX128, uint32 secondsInsideInitial, uint128 liquidity) = stakes(tokenId, incentiveId);
require(liquidity > 0, 'UniswapV3Staker::getRewardInfo: stake does not exist');

Deposit memory deposit = deposits[tokenId];
Incentive memory incentive = incentives[incentiveId];

(, uint160 secondsPerLiquidityInsideX128, ) =
(, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) =
key.pool.snapshotCumulativesInside(deposit.tickLower, deposit.tickUpper);

(reward, secondsInsideX128) = RewardMath.computeRewardAmount(
incentive.totalRewardUnclaimed,
incentive.totalSecondsClaimedX128,
key.startTime,
key.endTime,
liquidity,
secondsPerLiquidityInsideInitialX128,
secondsPerLiquidityInsideX128,
block.timestamp
(reward, maxReward, secondsInsideX128) = RewardMath.computeRewardAmount(
RewardMath.ComputeRewardAmountParams(
incentive.totalRewardUnclaimed,
incentive.totalSecondsClaimedX128,
key.startTime,
key.endTime,
key.vestingPeriod,
liquidity,
secondsPerLiquidityInsideInitialX128,
secondsPerLiquidityInsideX128,
secondsInsideInitial,
secondsInside,
block.timestamp
)
);
}

Expand Down Expand Up @@ -331,18 +362,20 @@ contract UniswapV3Staker is IUniswapV3Staker, Multicall {
deposits[tokenId].numberOfStakes++;
incentives[incentiveId].numberOfStakes++;

(, uint160 secondsPerLiquidityInsideX128, ) = pool.snapshotCumulativesInside(tickLower, tickUpper);
(, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) = pool.snapshotCumulativesInside(tickLower, tickUpper);

if (liquidity >= type(uint96).max) {
if (liquidity >= type(uint64).max) {
_stakes[tokenId][incentiveId] = Stake({
secondsPerLiquidityInsideInitialX128: secondsPerLiquidityInsideX128,
liquidityNoOverflow: type(uint96).max,
secondsInsideInitial: secondsInside,
liquidityNoOverflow: type(uint64).max,
liquidityIfOverflow: liquidity
});
} else {
Stake storage stake = _stakes[tokenId][incentiveId];
stake.secondsPerLiquidityInsideInitialX128 = secondsPerLiquidityInsideX128;
stake.liquidityNoOverflow = uint96(liquidity);
stake.secondsInsideInitial = secondsInside;
stake.liquidityNoOverflow = uint64(liquidity);
}

emit TokenStaked(tokenId, incentiveId, liquidity);
Expand Down
17 changes: 13 additions & 4 deletions contracts/interfaces/IUniswapV3Staker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ interface IUniswapV3Staker is IERC721Receiver, IMulticall {
/// @param pool The Uniswap V3 pool
/// @param startTime The time when the incentive program begins
/// @param endTime The time when rewards stop accruing
/// @param vestingPeriod The minimal in range period (in seconds) after which full rewards are payed out
/// @param refundee The address which receives any remaining reward tokens when the incentive is ended
struct IncentiveKey {
IERC20Minimal rewardToken;
IUniswapV3Pool pool;
uint256 startTime;
uint256 endTime;
uint256 vestingPeriod;
address refundee;
}

Expand All @@ -42,13 +44,15 @@ interface IUniswapV3Staker is IERC721Receiver, IMulticall {
/// @notice Represents a staking incentive
/// @param incentiveId The ID of the incentive computed from its parameters
/// @return totalRewardUnclaimed The amount of reward token not yet claimed by users
/// @return totalRewardLocked The amount of reward token locked because of incomplete vesting
/// @return totalSecondsClaimedX128 Total liquidity-seconds claimed, represented as a UQ32.128
/// @return numberOfStakes The count of deposits that are currently staked for the incentive
function incentives(bytes32 incentiveId)
external
view
returns (
uint256 totalRewardUnclaimed,
uint256 totalRewardLocked,
uint160 totalSecondsClaimedX128,
uint96 numberOfStakes
);
Expand All @@ -72,11 +76,12 @@ interface IUniswapV3Staker is IERC721Receiver, IMulticall {
/// @param tokenId The ID of the staked token
/// @param incentiveId The ID of the incentive for which the token is staked
/// @return secondsPerLiquidityInsideInitialX128 secondsPerLiquidity represented as a UQ32.128
/// @return secondsInsideInitial secondsInside value when staked
/// @return liquidity The amount of liquidity in the NFT as of the last time the rewards were computed
function stakes(uint256 tokenId, bytes32 incentiveId)
external
view
returns (uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity);
returns (uint160 secondsPerLiquidityInsideInitialX128, uint32 secondsInsideInitial, uint128 liquidity);

/// @notice Returns amounts of reward tokens owed to a given address according to the last time all stakes were updated
/// @param rewardToken The token for which to check rewards
Expand Down Expand Up @@ -133,23 +138,26 @@ interface IUniswapV3Staker is IERC721Receiver, IMulticall {
/// @notice Calculates the reward amount that will be received for the given stake
/// @param key The key of the incentive
/// @param tokenId The ID of the token
/// @return reward The reward accrued to the NFT for the given incentive thus far
/// @return reward The reward accrued to the NFT for the given incentive thus far (including vesting modifier)
/// @return maxReward The reward accrued to the NFT for the given incentive thus far
function getRewardInfo(IncentiveKey memory key, uint256 tokenId)
external
returns (uint256 reward, uint160 secondsInsideX128);
returns (uint256 reward, uint256 maxReward, uint160 secondsInsideX128);

/// @notice Event emitted when a liquidity mining incentive has been created
/// @param rewardToken The token being distributed as a reward
/// @param pool The Uniswap V3 pool
/// @param startTime The time when the incentive program begins
/// @param endTime The time when rewards stop accruing
/// @param vestingPeriod The minimal in range period (in seconds) after which full rewards are payed out
/// @param refundee The address which receives any remaining reward tokens after the end time
/// @param reward The amount of reward tokens to be distributed
event IncentiveCreated(
IERC20Minimal indexed rewardToken,
IUniswapV3Pool indexed pool,
uint256 startTime,
uint256 endTime,
uint256 vestingPeriod,
address refundee,
uint256 reward
);
Expand Down Expand Up @@ -177,7 +185,8 @@ interface IUniswapV3Staker is IERC721Receiver, IMulticall {
event TokenUnstaked(uint256 indexed tokenId, bytes32 indexed incentiveId);

/// @notice Event emitted when a reward token has been claimed
/// @param rewardToken Reward token which was claimed
/// @param to The address where claimed rewards were sent to
/// @param reward The amount of reward tokens claimed
event RewardClaimed(address indexed to, uint256 reward);
event RewardClaimed(IERC20Minimal indexed rewardToken, address indexed to, uint256 reward);
}
Loading