-
Notifications
You must be signed in to change notification settings - Fork 7
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
feat: Add ClaimAndStake contract #32
Changes from 10 commits
834502c
ed5e42a
2394332
13c87c5
23e9451
0dbd45b
7af3054
1af5cb6
8ffcf6f
90f982f
0fa124e
c630a94
c6816bc
8c193e2
fcd29da
030b6c6
5f0661f
fe02e96
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -182,26 +182,7 @@ contract AcceleratingDistributor is ReentrancyGuard, Ownable, Multicall { | |
* @param amount The amount of the token to stake. | ||
*/ | ||
function stake(address stakedToken, uint256 amount) external nonReentrant onlyEnabled(stakedToken) { | ||
_updateReward(stakedToken, msg.sender); | ||
|
||
UserDeposit storage userDeposit = stakingTokens[stakedToken].stakingBalances[msg.sender]; | ||
|
||
uint256 averageDepositTime = getAverageDepositTimePostDeposit(stakedToken, msg.sender, amount); | ||
|
||
userDeposit.averageDepositTime = averageDepositTime; | ||
userDeposit.cumulativeBalance += amount; | ||
stakingTokens[stakedToken].cumulativeStaked += amount; | ||
|
||
IERC20(stakedToken).safeTransferFrom(msg.sender, address(this), amount); | ||
|
||
emit Stake( | ||
stakedToken, | ||
msg.sender, | ||
amount, | ||
averageDepositTime, | ||
userDeposit.cumulativeBalance, | ||
stakingTokens[stakedToken].cumulativeStaked | ||
); | ||
_stake(stakedToken, amount, msg.sender); | ||
} | ||
|
||
/** | ||
|
@@ -391,4 +372,31 @@ contract AcceleratingDistributor is ReentrancyGuard, Ownable, Multicall { | |
userDeposit.rewardsAccumulatedPerToken = stakingToken.rewardPerTokenStored; | ||
} | ||
} | ||
|
||
function _stake( | ||
address stakedToken, | ||
uint256 amount, | ||
address staker | ||
) internal { | ||
_updateReward(stakedToken, staker); | ||
|
||
UserDeposit storage userDeposit = stakingTokens[stakedToken].stakingBalances[staker]; | ||
|
||
uint256 averageDepositTime = getAverageDepositTimePostDeposit(stakedToken, staker, amount); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is existing code but in getAverageDepositTimePostDeposit, I see uint256 amountWeightedTime = (((amount * 1e18) / (userDeposit.cumulativeBalance + amount)) * Why not multiple first before dividing by (userDeposit.cumulativeBalance + amount) to minimize rounding errors? Also there are way too many parentheses there. uint256 amountWeightedTime = (amount * 1e18) * getTimeSinceAverageDeposit(stakedToken, account) / (userDeposit.cumulativeBalance + amount) / 1e18; There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are good points but I don't love modifying existing already-audited code. @chrismaree wdyt? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i dont think we touch this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kevinuma In general, we would prefer to modify the code as little as possible. The only exception is if we would classify this as a bug. Is the rounding error substantial enough to consider it a bug? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rounding error is likely not significant unless a user has staked a significant amount of tokens and then add a very small amount. |
||
|
||
userDeposit.averageDepositTime = averageDepositTime; | ||
userDeposit.cumulativeBalance += amount; | ||
stakingTokens[stakedToken].cumulativeStaked += amount; | ||
|
||
IERC20(stakedToken).safeTransferFrom(staker, address(this), amount); | ||
|
||
emit Stake( | ||
stakedToken, | ||
staker, | ||
amount, | ||
averageDepositTime, | ||
userDeposit.cumulativeBalance, | ||
stakingTokens[stakedToken].cumulativeStaked | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
// SPDX-License-Identifier: GPL-3.0-only | ||
pragma solidity ^0.8.0; | ||
|
||
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; | ||
import "@openzeppelin/contracts/access/Ownable.sol"; | ||
import "@openzeppelin/contracts/utils/Multicall.sol"; | ||
import "@uma/core/contracts/merkle-distributor/implementation/MerkleDistributorInterface.sol"; | ||
import "./AcceleratingDistributor.sol"; | ||
|
||
/** | ||
* @notice Across token distribution contract. Contract is inspired by Synthetix staking contract and Ampleforth geyser. | ||
* Stakers start by earning their pro-rata share of a baseEmissionRate per second which increases based on how long | ||
* they have staked in the contract, up to a max emission rate of baseEmissionRate * maxMultiplier. Multiple LP tokens | ||
* can be staked in this contract enabling depositors to batch stake and claim via multicall. Note that this contract is | ||
* only compatible with standard ERC20 tokens, and not tokens that charge fees on transfers, dynamically change | ||
* balance, or have double entry-points. It's up to the contract owner to ensure they only add supported tokens. | ||
*/ | ||
|
||
contract AcceleratingDistributorClaimAndStake is AcceleratingDistributor { | ||
// Contract which rewards tokens to users that they can then stake. MerkleDistributor logic does not impact | ||
// this contract at all, but its stored here for convenience to allow claimAndStake to be called by a user to | ||
// claim their staking tokens and stake atomically. | ||
MerkleDistributorInterface public merkleDistributor; | ||
|
||
/************************************** | ||
* EVENTS * | ||
**************************************/ | ||
|
||
event SetMerkleDistributor(address indexed newMerkleDistributor); | ||
|
||
constructor(address _rewardToken) AcceleratingDistributor(_rewardToken) {} | ||
|
||
/************************************** | ||
* ADMIN FUNCTIONS * | ||
**************************************/ | ||
|
||
/** | ||
* @notice Resets merkle distributor contract called in claimAndStake() | ||
* @param _merkleDistributor Address to set merkleDistributor to. | ||
*/ | ||
function setMerkleDistributor(address _merkleDistributor) external onlyOwner { | ||
merkleDistributor = MerkleDistributorInterface(_merkleDistributor); | ||
emit SetMerkleDistributor(_merkleDistributor); | ||
} | ||
|
||
/************************************** | ||
* STAKER FUNCTIONS * | ||
**************************************/ | ||
|
||
/** | ||
* @notice Claim tokens from a MerkleDistributor contract and stake them for rewards. | ||
* @dev Will revert if `merkleDistributor` is not set to valid MerkleDistributor contract. | ||
* @dev Will revert if any of the claims recipient accounts are not equal to caller, or if any reward token | ||
* for claim is not a valid staking token or are not the same token as the other claims. | ||
* @dev Will revert if this contract is not a "whitelisted claimer" on the MerkleDistributor contract. | ||
* @dev The caller of this function must approve this contract to spend total amount of stakedToken. | ||
* @param claims Claim leaves to retrieve from MerkleDistributor. | ||
* @param stakedToken The address of the token to stake. | ||
*/ | ||
function claimAndStake(MerkleDistributorInterface.Claim[] memory claims, address stakedToken) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't want to throw a wrench into anything, but I was brainstorming a bit and wanted to ask about a slightly different implementation/strategy. This would introduce less code in this contract, but may add more code (or more complex code) in the merkle distributor and may have a slightly better business outcome (one transaction, no approval):
This process has a few advantages:
Downsides:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is a good idea I'll start work on the AcrossMerkleDistributor |
||
external | ||
nonReentrant | ||
onlyEnabled(stakedToken) | ||
{ | ||
uint256 batchedAmount; | ||
uint256 claimCount = claims.length; | ||
for (uint256 i = 0; i < claimCount; i++) { | ||
MerkleDistributorInterface.Claim memory _claim = claims[i]; | ||
require(_claim.account == msg.sender, "claim account not caller"); | ||
require( | ||
merkleDistributor.getRewardTokenForWindow(_claim.windowIndex) == stakedToken, | ||
kevinuma marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"unexpected claim token" | ||
); | ||
batchedAmount += _claim.amount; | ||
} | ||
merkleDistributor.claimMulti(claims); | ||
_stake(stakedToken, batchedAmount, msg.sender); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
// SPDX-License-Identifier: GPL-3.0-only | ||
import "@across-protocol/contracts-v2/contracts/merkle-distributor/AcrossMerkleDistributor.sol"; | ||
|
||
pragma solidity ^0.8.0; | ||
|
||
/// @notice Pass through contract that allows tests to access MerkleDistributor from /artifacts via | ||
// utils.getContractFactory() | ||
contract MerkleDistributorTest is AcrossMerkleDistributor { | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import { expect, ethers, Contract, SignerWithAddress, toWei, toBN } from "./utils"; | ||
import { acceleratingDistributorFixture, enableTokenForStaking } from "./AcceleratingDistributor.Fixture"; | ||
import { MAX_UINT_VAL } from "@uma/common"; | ||
import { MerkleTree } from "@uma/merkle-distributor"; | ||
import { baseEmissionRate, maxMultiplier, secondsToMaxMultiplier } from "./constants"; | ||
|
||
let acrossToken: Contract, distributor: Contract, lpToken1: Contract, claimer: SignerWithAddress; | ||
let merkleDistributor: Contract, contractCreator: SignerWithAddress, lpToken2: Contract; | ||
|
||
type Recipient = { | ||
account: string; | ||
amount: string; | ||
accountIndex: number; | ||
}; | ||
|
||
type RecipientWithProof = Recipient & { | ||
windowIndex: number; | ||
merkleProof: Buffer[]; | ||
}; | ||
|
||
const createLeaf = (recipient: Recipient) => { | ||
expect(Object.keys(recipient).every((val) => ["account", "amount", "accountIndex"].includes(val))).to.be.true; | ||
|
||
return Buffer.from( | ||
ethers.utils | ||
.solidityKeccak256( | ||
["address", "uint256", "uint256"], | ||
[recipient.account, recipient.amount, recipient.accountIndex] | ||
) | ||
.slice(2), | ||
"hex" | ||
); | ||
}; | ||
|
||
const window1RewardAmount = toBN(toWei("100")); | ||
const window2RewardAmount = toBN(toWei("300")); | ||
const totalBatchRewards = window1RewardAmount.add(window2RewardAmount); | ||
let batchedClaims: RecipientWithProof[]; | ||
|
||
describe("AcceleratingDistributor: Atomic Claim and Stake", async function () { | ||
beforeEach(async function () { | ||
[contractCreator, claimer] = await ethers.getSigners(); | ||
({ distributor, acrossToken, lpToken1, lpToken2, merkleDistributor } = await acceleratingDistributorFixture()); | ||
nicholaspai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Enable reward token for staking. | ||
await enableTokenForStaking(distributor, lpToken1, acrossToken); | ||
|
||
// Seed MerkleDistributor with reward tokens. | ||
await lpToken1.connect(contractCreator).mint(contractCreator.address, MAX_UINT_VAL); | ||
await lpToken1.connect(contractCreator).approve(merkleDistributor.address, MAX_UINT_VAL); | ||
|
||
// Set two windows with trivial one leaf trees. | ||
const reward1Recipients = [ | ||
{ | ||
account: claimer.address, | ||
amount: window1RewardAmount.toString(), | ||
accountIndex: 0, | ||
}, | ||
]; | ||
const reward2Recipients = [ | ||
{ | ||
account: claimer.address, | ||
amount: window2RewardAmount.toString(), | ||
accountIndex: 0, | ||
}, | ||
]; | ||
|
||
const merkleTree1 = new MerkleTree(reward1Recipients.map((item) => createLeaf(item))); | ||
await merkleDistributor | ||
.connect(contractCreator) | ||
.setWindow(window1RewardAmount, lpToken1.address, merkleTree1.getRoot(), ""); | ||
|
||
const merkleTree2 = new MerkleTree(reward2Recipients.map((item) => createLeaf(item))); | ||
await merkleDistributor | ||
.connect(contractCreator) | ||
.setWindow(window2RewardAmount, lpToken1.address, merkleTree2.getRoot(), ""); | ||
|
||
// Construct claims for all trees assuming that each tree index is equal to its window index. | ||
batchedClaims = [ | ||
{ | ||
windowIndex: 0, | ||
account: reward1Recipients[0].account, | ||
accountIndex: reward1Recipients[0].accountIndex, | ||
amount: reward1Recipients[0].amount, | ||
merkleProof: merkleTree1.getProof(createLeaf(reward1Recipients[0])), | ||
}, | ||
{ | ||
windowIndex: 1, | ||
account: reward2Recipients[0].account, | ||
accountIndex: reward2Recipients[0].accountIndex, | ||
amount: reward2Recipients[0].amount, | ||
merkleProof: merkleTree2.getProof(createLeaf(reward2Recipients[0])), | ||
}, | ||
]; | ||
expect(await lpToken1.balanceOf(claimer.address)).to.equal(toBN(0)); | ||
|
||
// Tests require staker to have approved contract | ||
await lpToken1.connect(claimer).approve(distributor.address, MAX_UINT_VAL); | ||
}); | ||
|
||
it("Happy path", async function () { | ||
const time = await distributor.getCurrentTime(); | ||
await expect(distributor.connect(claimer).claimAndStake(batchedClaims, lpToken1.address)) | ||
.to.emit(distributor, "Stake") | ||
.withArgs(lpToken1.address, claimer.address, totalBatchRewards, time, totalBatchRewards, totalBatchRewards); | ||
expect((await distributor.getUserStake(lpToken1.address, claimer.address)).cumulativeBalance).to.equal( | ||
totalBatchRewards | ||
); | ||
expect(await lpToken1.balanceOf(merkleDistributor.address)).to.equal(toBN(0)); | ||
expect(await lpToken1.balanceOf(claimer.address)).to.equal(toBN(0)); | ||
}); | ||
it("Fails if AcceleratingDistributor is not whitelisted claimer on MerkleDistributor", async function () { | ||
await merkleDistributor.whitelistClaimer(distributor.address, false); | ||
await expect(distributor.connect(claimer).claimAndStake(batchedClaims, lpToken1.address)).to.be.revertedWith( | ||
"invalid claimer" | ||
); | ||
}); | ||
it("MerkleDistributor set to invalid address", async function () { | ||
await distributor.setMerkleDistributor(distributor.address); | ||
// distributor is not a valid MerkleDistributor and error explains that. | ||
await expect(distributor.connect(claimer).claimAndStake(batchedClaims, lpToken1.address)).to.be.revertedWith( | ||
"function selector was not recognized and there's no fallback function" | ||
); | ||
}); | ||
it("Only owner can set MerkleDistributor address", async function () { | ||
await expect(distributor.connect(claimer).setMerkleDistributor(distributor.address)).to.be.revertedWith( | ||
"Ownable: caller is not the owner" | ||
); | ||
}); | ||
it("One claim account is not caller", async function () { | ||
// Claiming with account that isn't receiving the claims causes revert | ||
await expect( | ||
distributor.connect(contractCreator).claimAndStake(batchedClaims, lpToken1.address) | ||
).to.be.revertedWith("claim account not caller"); | ||
}); | ||
it("One claim reward token is not staked token", async function () { | ||
// Enable new staking token that doesn't match claims. | ||
await distributor.configureStakingToken( | ||
lpToken2.address, | ||
true, | ||
baseEmissionRate, | ||
maxMultiplier, | ||
secondsToMaxMultiplier | ||
); | ||
await expect(distributor.connect(claimer).claimAndStake(batchedClaims, lpToken2.address)).to.be.revertedWith( | ||
"unexpected claim token" | ||
); | ||
}); | ||
it("Claimed token is not eligible for staking", async function () { | ||
kevinuma marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Disable staking token | ||
await distributor.configureStakingToken( | ||
lpToken1.address, | ||
false, | ||
baseEmissionRate, | ||
maxMultiplier, | ||
secondsToMaxMultiplier | ||
); | ||
await expect(distributor.connect(claimer).claimAndStake(batchedClaims, lpToken1.address)).to.be.revertedWith( | ||
"stakedToken not enabled" | ||
); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be copied exactly to new
_stake
internal method