Skip to content

Commit

Permalink
feat: Protocol fees distributor (#46)
Browse files Browse the repository at this point in the history
* feat: ProtocolFeesDistributor

* test: Revert back to hardhat

* docs: Update author

* feat: Blast points integration

* chore: Split roles into owner and operator

* chore: Use encode instead of encodePacked

* feat: Can claim until

* chore: Merkle root can be reused

* fix: Remove use of merkleRootUsed

* feat: Only allow update merkle root when paused

* build: Solidity 0.8.23
  • Loading branch information
0xhiroshi authored Apr 3, 2024
1 parent 066af48 commit 63cdc46
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 29 deletions.
31 changes: 20 additions & 11 deletions contracts/ProtocolFeesDistributor.sol
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma solidity 0.8.23;

import {LowLevelWETH} from "@looksrare/contracts-libs/contracts/lowLevelCallers/LowLevelWETH.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
Expand All @@ -24,6 +24,9 @@ contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, Lo
// Current round (users can only claim pending protocol fees for the current round)
uint256 public currentRound;

// Users can claim until this timestamp
uint256 public canClaimUntil;

// Max amount per user in current tree
uint256 public maximumAmountPerUserInCurrentTree;

Expand All @@ -33,20 +36,18 @@ contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, Lo
// Merkle root for a round
mapping(uint256 => bytes32) public merkleRootOfRound;

// Checks whether a merkle root was used
mapping(bytes32 => bool) public merkleRootUsed;

// Keeps track on whether user has claimed at a given round
mapping(uint256 => mapping(address => bool)) public hasUserClaimedForRound;

event ProtocolFeesClaimed(address indexed user, uint256 indexed round, uint256 amount);
event ProtocolFeesDistributionUpdated(uint256 indexed round);
event EthWithdrawn(uint256 amount);
event CanClaimUntilUpdated(uint256 timestamp);

error AlreadyClaimed();
error AmountHigherThanMax();
error ClaimPeriodEnded();
error InvalidProof();
error MerkleRootAlreadyUsed();

/**
* @notice Constructor
Expand All @@ -66,7 +67,6 @@ contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, Lo
address _blastPointsOperator
) {
WETH = _weth;
merkleRootUsed[bytes32(0)] = true;

_grantRole(DEFAULT_ADMIN_ROLE, _owner);
_grantRole(OPERATOR_ROLE, _owner);
Expand All @@ -87,6 +87,10 @@ contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, Lo
revert AlreadyClaimed();
}

if (block.timestamp >= canClaimUntil) {
revert ClaimPeriodEnded();
}

(bool claimStatus, uint256 adjustedAmount) = _canClaim(msg.sender, amount, merkleProof);

if (!claimStatus) {
Expand Down Expand Up @@ -121,20 +125,21 @@ contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, Lo
function updateProtocolFeesDistribution(bytes32 merkleRoot, uint256 newMaximumAmountPerUser)
external
payable
whenPaused
onlyRole(OPERATOR_ROLE)
{
if (merkleRootUsed[merkleRoot]) {
revert MerkleRootAlreadyUsed();
}

currentRound++;
merkleRootOfRound[currentRound] = merkleRoot;
merkleRootUsed[merkleRoot] = true;
maximumAmountPerUserInCurrentTree = newMaximumAmountPerUser;

emit ProtocolFeesDistributionUpdated(currentRound);
}

function updateCanClaimUntil(uint256 timestamp) external onlyRole(OPERATOR_ROLE) {
canClaimUntil = timestamp;
emit CanClaimUntilUpdated(timestamp);
}

/**
* @notice Pause claim
*/
Expand Down Expand Up @@ -170,6 +175,10 @@ contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, Lo
uint256 amount,
bytes32[] calldata merkleProof
) external view returns (bool, uint256) {
if (block.timestamp >= canClaimUntil) {
return (false, 0);
}

return _canClaim(user, amount, merkleProof);
}

Expand Down
4 changes: 4 additions & 0 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const config: HardhatUserConfig = {
},
solidity: {
compilers: [
{
version: "0.8.23",
settings: { optimizer: { enabled: true, runs: 888888 } },
},
{
version: "0.8.19",
settings: { optimizer: { enabled: true, runs: 888888 } },
Expand Down
101 changes: 83 additions & 18 deletions test/protocolFeesDistributor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ describe("ProtocolFeesDistributor", () => {

let admin: SignerWithAddress;
let accounts: SignerWithAddress[];
let blockTimestamp;

const getBlockTimestamp = async () => {
const blockNumber = await ethers.provider.getBlockNumber();
const block = await ethers.provider.getBlock(blockNumber);
return block.timestamp;
};

beforeEach(async () => {
accounts = await ethers.getSigners();
Expand Down Expand Up @@ -42,11 +49,21 @@ describe("ProtocolFeesDistributor", () => {

let tree = StandardMerkleTree.of(values, ["address", "uint256"]);

await protocolFeesDistributor.connect(admin).pause();

let tx = await protocolFeesDistributor
.connect(admin)
.updateProtocolFeesDistribution(tree.root, parseEther("5000"), { value: parseEther("10000") });
await expect(tx).to.emit(protocolFeesDistributor, "ProtocolFeesDistributionUpdated").withArgs("1");

await protocolFeesDistributor.connect(admin).unpause();

blockTimestamp = await getBlockTimestamp();
tx = await protocolFeesDistributor.connect(admin).updateCanClaimUntil(blockTimestamp + 100);
await expect(tx)
.to.emit(protocolFeesDistributor, "CanClaimUntilUpdated")
.withArgs(blockTimestamp + 100);

// All users except the 4th one claims
for (const [index, [user, value]] of tree.entries()) {
const signedUser = accounts[Number(index) + 1];
Expand Down Expand Up @@ -101,11 +118,15 @@ describe("ProtocolFeesDistributor", () => {

tree = StandardMerkleTree.of(values2, ["address", "uint256"]);

await protocolFeesDistributor.connect(admin).pause();

tx = await protocolFeesDistributor
.connect(admin)
.updateProtocolFeesDistribution(tree.root, parseEther("8000"), { value: parseEther("10000") });
await expect(tx).to.emit(protocolFeesDistributor, "ProtocolFeesDistributionUpdated").withArgs("2");

await protocolFeesDistributor.connect(admin).unpause();

// All users except the 4th one claims
for (const [index, [user, value]] of tree.entries()) {
const signedUser = accounts[Number(index) + 1];
Expand Down Expand Up @@ -181,6 +202,12 @@ describe("ProtocolFeesDistributor", () => {
});

it("Claim - Users cannot claim with wrong proofs", async () => {
blockTimestamp = await getBlockTimestamp();
const tx = await protocolFeesDistributor.connect(admin).updateCanClaimUntil(blockTimestamp + 100);
await expect(tx)
.to.emit(protocolFeesDistributor, "CanClaimUntilUpdated")
.withArgs(blockTimestamp + 100);

// Users 1 to 4
const values = [
["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", parseEther("5000").toString()],
Expand All @@ -203,9 +230,13 @@ describe("ProtocolFeesDistributor", () => {
const hexProof1 = tree.getProof([user1.address, expectedAmountToReceiveForUser1.toString()]);
const hexProof2 = tree.getProof([user2.address, expectedAmountToReceiveForUser2.toString()]);

await protocolFeesDistributor.connect(admin).pause();

// Owner adds protocol fees and unpause distribution
await protocolFeesDistributor.connect(admin).updateProtocolFeesDistribution(tree.root, parseEther("5000"));

await protocolFeesDistributor.connect(admin).unpause();

// 1. Verify leafs for user1/user2 are matched in the tree with the computed root
assert.isTrue(
StandardMerkleTree.verify(
Expand Down Expand Up @@ -335,6 +366,12 @@ describe("ProtocolFeesDistributor", () => {
});

it("Claim - User cannot claim if error in tree computation due to amount too high", async () => {
blockTimestamp = await getBlockTimestamp();
const tx = await protocolFeesDistributor.connect(admin).updateCanClaimUntil(blockTimestamp + 100);
await expect(tx)
.to.emit(protocolFeesDistributor, "CanClaimUntilUpdated")
.withArgs(blockTimestamp + 100);

// Users 1 to 4
const values = [
["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", parseEther("5000").toString()],
Expand All @@ -352,13 +389,59 @@ describe("ProtocolFeesDistributor", () => {
// Compute the proof for user1/user2
const hexProof1 = tree.getProof([user1.address, expectedAmountToReceiveForUser1.toString()]);

await protocolFeesDistributor.connect(admin).pause();

// Owner adds protocol fees and unpause distribution
await protocolFeesDistributor.connect(admin).updateProtocolFeesDistribution(tree.root, parseEther("4999.9999"));

await protocolFeesDistributor.connect(admin).unpause();

await expect(
protocolFeesDistributor.connect(user1).claim(expectedAmountToReceiveForUser1, hexProof1)
).to.be.revertedWith("AmountHigherThanMax()");
});

it("Claim - Users cannot claim if the current timestamp is >= canClaimUntil", async () => {
// Users 1 to 4
const values = [
["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", parseEther("5000").toString()],
["0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", parseEther("3000").toString()],
["0x90F79bf6EB2c4f870365E785982E1f101E93b906", parseEther("1000").toString()],
["0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", parseEther("1000").toString()],
];

const tree = StandardMerkleTree.of(values, ["address", "uint256"]);

await protocolFeesDistributor.connect(admin).pause();

let tx = await protocolFeesDistributor
.connect(admin)
.updateProtocolFeesDistribution(tree.root, parseEther("5000"), { value: parseEther("10000") });
await expect(tx).to.emit(protocolFeesDistributor, "ProtocolFeesDistributionUpdated").withArgs("1");

await protocolFeesDistributor.connect(admin).unpause();

blockTimestamp = await getBlockTimestamp();
tx = await protocolFeesDistributor.connect(admin).updateCanClaimUntil(blockTimestamp);
await expect(tx).to.emit(protocolFeesDistributor, "CanClaimUntilUpdated").withArgs(blockTimestamp);

// All users except the 4th one claims
for (const [index, [user, value]] of tree.entries()) {

Check warning on line 429 in test/protocolFeesDistributor.test.ts

View workflow job for this annotation

GitHub Actions / format

'user' is assigned a value but never used
const signedUser = accounts[Number(index) + 1];

if (signedUser === accounts[3]) {
break;
}

// Compute the proof for the user
const hexProof = tree.getProof(index);

// Cannot double claim
await expect(protocolFeesDistributor.connect(signedUser).claim(value, hexProof)).to.be.revertedWith(
"ClaimPeriodEnded()"
);
}
});
});

describe("#2 - Owner functions", async () => {
Expand Down Expand Up @@ -404,23 +487,5 @@ describe("ProtocolFeesDistributor", () => {
beforeWithdrawBalance.add(depositAmount).sub(txFee).toString()
);
});

it("Owner - Owner cannot set twice the same Merkle Root", async () => {
// Users 1 to 4
const values = [
["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", parseEther("5000").toString()],
["0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", parseEther("3000").toString()],
["0x90F79bf6EB2c4f870365E785982E1f101E93b906", parseEther("1000").toString()],
["0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", parseEther("1000").toString()],
];

const tree = StandardMerkleTree.of(values, ["address", "uint256"]);

await protocolFeesDistributor.connect(admin).updateProtocolFeesDistribution(tree.root, parseEther("5000"));

await expect(
protocolFeesDistributor.connect(admin).updateProtocolFeesDistribution(tree.root, parseEther("5000"))
).to.be.revertedWith("MerkleRootAlreadyUsed()");
});
});
});

0 comments on commit 63cdc46

Please sign in to comment.