-
Notifications
You must be signed in to change notification settings - Fork 55
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
BAL Hookathon - Swap Bond Hook #89
base: main
Are you sure you want to change the base?
Changes from all commits
5a81e1d
b5ae743
7bc8720
4732434
4114108
3153b2a
0bed770
4c2d05f
00e6692
64dbba6
5040636
a762685
e7a2186
12ed1c7
9b2ad52
e83c6bc
c28366a
e5dd837
0fbd367
a0bbb3c
d9aaaa3
ea61ec7
936b155
29c333a
30e8787
3a45cd3
22c9fd1
ef00404
9aef2bb
dcba084
23ae8ea
689947c
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 |
---|---|---|
@@ -1,5 +1,5 @@ | ||
node_modules | ||
|
||
bin | ||
# dependencies, yarn, etc | ||
# yarn / eslint | ||
.yarn/* | ||
|
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.24; | ||
|
||
import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; | ||
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; | ||
import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; | ||
|
||
import { IDiscountCampaign } from "./Interfaces/IDiscountCampaign.sol"; | ||
import { ISwapDiscountHook } from "./Interfaces/ISwapDiscountHook.sol"; | ||
import { TransferHelper } from "./libraries/TransferHelper.sol"; | ||
|
||
/** | ||
* @title DiscountCampaign | ||
* @notice This contract is used to manage discount campaigns, allowing users to earn rewards through swaps. | ||
* @dev Implements IDiscountCampaign interface. Includes reward distribution, user discount data updates, and campaign management. | ||
*/ | ||
contract DiscountCampaign is IDiscountCampaign, Ownable, ReentrancyGuard { | ||
/// @notice Maps token IDs to user-specific swap data. | ||
mapping(uint256 => UserSwapData) public override userDiscountMapping; | ||
|
||
/// @notice Holds the details of the current discount campaign. | ||
CampaignDetails public campaignDetails; | ||
|
||
/// @notice Total amount of reward tokens distributed so far. | ||
uint256 public tokenRewardDistributed; | ||
|
||
/// @notice Address of the discount campaign factory that can manage campaign updates. | ||
address public discountCampaignFactory; | ||
|
||
/// @notice Address of the swap discount hook for tracking user swaps. | ||
ISwapDiscountHook private _swapHook; | ||
|
||
/// @notice Maximum buyable reward amount during the campaign. | ||
uint256 private _maxBuy; | ||
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 a cap on the trade amount; i.e., rewards are proportional to the swap size, but only up to a point, after which it's flat. Maybe 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. yes it is swapAmountCap which is related to the rewardAmount so if the swapAmount exceeds the cap then we'll calculate the reward based on the maxCap. |
||
|
||
/// @notice Maximum discount rate available in the campaign. | ||
uint256 private _maxDiscountRate; | ||
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. Document the units here. If this is a percentage, we'd typically say 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've updated the document and variable name |
||
|
||
/** | ||
* @notice Initializes the discount campaign contract with the provided details. | ||
* @dev Sets the campaign details, owner, and swap hook address during contract deployment. | ||
* @param _campaignDetails A struct containing reward amount, expiration time, cooldown period, discount rate, and reward token. | ||
* @param _owner The owner address of the discount campaign contract. | ||
* @param _hook The address of the swap hook for tracking user discounts. | ||
* @param _discountCampaignFactory The address of the discount campaign factory. | ||
*/ | ||
constructor( | ||
CampaignDetails memory _campaignDetails, | ||
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 a nit, but 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. Fixed, will be updated in the updated PR |
||
address _owner, | ||
address _hook, | ||
address _discountCampaignFactory | ||
) Ownable(_owner) { | ||
campaignDetails = _campaignDetails; | ||
_swapHook = ISwapDiscountHook(_hook); | ||
_maxBuy = _campaignDetails.rewardAmount; | ||
_maxDiscountRate = _campaignDetails.discountRate; | ||
discountCampaignFactory = _discountCampaignFactory; | ||
} | ||
|
||
/** | ||
* @notice Modifier to restrict access to the factory contract. | ||
* @dev Reverts with `NOT_AUTHORIZED` if the caller is not the factory. | ||
*/ | ||
modifier onlyFactory() { | ||
if (msg.sender != discountCampaignFactory) { | ||
revert NOT_AUTHORIZED(); | ||
} | ||
_; | ||
} | ||
|
||
/** | ||
* @notice Modifier to check and authorize a token ID before processing claims. | ||
* @dev Ensures the token is valid, the campaign has not expired, and the reward has not been claimed. | ||
* @param tokenID The ID of the token to be validated. | ||
*/ | ||
modifier checkAndAuthorizeTokenId(uint256 tokenID) { | ||
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. Seems this is only used once, so doesn't need to be a modifier. Consider just making it a private 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. fixed, will be updated in the next PR |
||
UserSwapData memory userSwapData = userDiscountMapping[tokenID]; | ||
|
||
// Ensure the campaign address matches the current contract address | ||
if (userSwapData.campaignAddress != address(this)) { | ||
revert InvalidTokenID(); | ||
} | ||
|
||
// Ensure the campaign ID matches the current campaign ID | ||
if (userSwapData.campaignID != campaignDetails.campaignID) { | ||
revert CampaignExpired(); | ||
} | ||
|
||
// Check if the swap happened before the campaign expiration | ||
if (block.timestamp > campaignDetails.expirationTime) { | ||
revert DiscountExpired(); | ||
} | ||
|
||
// Ensure the reward hasn't already been claimed | ||
if (userSwapData.hasClaimed) { | ||
revert RewardAlreadyClaimed(); | ||
} | ||
|
||
// Ensure the cooldown period has passed | ||
if (userSwapData.timeOfSwap + campaignDetails.coolDownPeriod > block.timestamp) { | ||
revert CoolDownPeriodNotPassed(); | ||
} | ||
_; | ||
} | ||
|
||
/** | ||
* @notice Updates the campaign details. | ||
* @dev Can only be called by the factory contract. This will replace the existing campaign parameters. | ||
* @param _newCampaignDetails A struct containing updated reward amount, expiration time, cooldown period, discount rate, and reward token. | ||
*/ | ||
function updateCampaignDetails(CampaignDetails calldata _newCampaignDetails) external onlyFactory { | ||
campaignDetails = _newCampaignDetails; | ||
emit CampaignDetailsUpdated(_newCampaignDetails); | ||
} | ||
|
||
/** | ||
* @notice Allows the SwapDiscountHook to update the user discount mapping after a swap. | ||
* @dev Can only be called by the SwapDiscountHook contract. | ||
* @param campaignID The ID of the campaign associated with the user. | ||
* @param tokenId The token ID for which the discount data is being updated. | ||
* @param user The address of the user receiving the discount. | ||
* @param swappedAmount The amount that was swapped. | ||
* @param timeOfSwap The timestamp of when the swap occurred. | ||
*/ | ||
function updateUserDiscountMapping( | ||
bytes32 campaignID, | ||
uint256 tokenId, | ||
address user, | ||
uint256 swappedAmount, | ||
uint256 timeOfSwap | ||
) external override { | ||
require(msg.sender == address(_swapHook), "Unauthorized"); | ||
|
||
userDiscountMapping[tokenId] = UserSwapData({ | ||
campaignID: campaignID, | ||
userAddress: user, | ||
campaignAddress: address(this), | ||
swappedAmount: swappedAmount, | ||
timeOfSwap: timeOfSwap, | ||
hasClaimed: false | ||
}); | ||
} | ||
|
||
/** | ||
* @notice Claims rewards for a specific token ID. | ||
* @dev Transfers the reward to the user associated with the token and marks the token as claimed. | ||
* Reverts if the reward amount is zero or if the total rewards have been distributed. | ||
* @param tokenID The ID of the token for which the claim is made. | ||
*/ | ||
function claim(uint256 tokenID) public checkAndAuthorizeTokenId(tokenID) nonReentrant { | ||
UserSwapData memory userSwapData = userDiscountMapping[tokenID]; | ||
uint256 reward = _getClaimableRewards(userSwapData); | ||
|
||
if (reward == 0) { | ||
revert RewardAmountExpired(); | ||
} | ||
|
||
tokenRewardDistributed += reward; | ||
_maxBuy -= reward; | ||
userDiscountMapping[tokenID].hasClaimed = true; | ||
TransferHelper.safeTransfer(campaignDetails.rewardToken, userSwapData.userAddress, reward); | ||
_updateDiscount(); | ||
} | ||
|
||
/** | ||
* @notice Internal function to calculate the claimable reward for a given token ID. | ||
* @dev The reward is calculated based on the swapped amount and discount rate. | ||
* @param tokenID The ID of the token for which to calculate the reward. | ||
* @return claimableReward The amount of reward that can be claimed. | ||
*/ | ||
function getClaimableReward(uint256 tokenID) external view returns (uint256 claimableReward) { | ||
UserSwapData memory userSwapData = userDiscountMapping[tokenID]; | ||
return _getClaimableRewards(userSwapData); | ||
} | ||
|
||
/** | ||
* @notice Overloaded version of _getClaimableRewards that takes UserSwapData as input. | ||
* @param userSwapData The UserSwapData struct containing the necessary information for calculating rewards. | ||
* @return claimableReward The amount of reward that can be claimed. | ||
*/ | ||
function _getClaimableRewards(UserSwapData memory userSwapData) private view returns (uint256 claimableReward) { | ||
uint256 swappedAmount = userSwapData.swappedAmount; | ||
|
||
// Calculate claimable reward based on the swapped amount and discount rate | ||
if (swappedAmount <= _maxBuy) { | ||
claimableReward = (swappedAmount * campaignDetails.discountRate) / 100e18; | ||
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 rest of the system uses FixedPoint math (i.e., 1 = 1e18). For instance, a 20% rate would be 20e16. So 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. Updated, will be highlighted in the next PR |
||
} else { | ||
claimableReward = (_maxBuy * campaignDetails.discountRate) / 100e18; | ||
} | ||
} | ||
|
||
/** | ||
* @notice Updates the discount rate based on the distributed rewards. | ||
* @dev The discount rate decreases proportionally as more rewards are distributed. | ||
*/ | ||
function _updateDiscount() private { | ||
campaignDetails.discountRate = | ||
(_maxDiscountRate * (campaignDetails.rewardAmount - tokenRewardDistributed)) / | ||
campaignDetails.rewardAmount; | ||
} | ||
|
||
/** | ||
* @notice Recovers any ERC20 tokens that are mistakenly sent to the contract. | ||
* @dev Can only be called by the contract owner. | ||
* @param tokenAddress Address of the ERC20 token to recover. | ||
* @param tokenAmount Amount of tokens to recover. | ||
*/ | ||
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner { | ||
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 would let the owner remove all the reward tokens, and partial removal would break the discount logic, since it's outside the balance accounting. I don't think anyone would use this with this function here; people just need to not send tokens where they shouldn't. If you just send tokens to the Vault, they're lost (v2 or v3). Same with the factory. Not an obvious security hole there, as the factory shouldn't get any tokens. You can block people sending ETH if you want (as we do in the v3 Vault). I suppose you could keep track of all the reward tokens, and allow withdrawing tokens that were not rewards, but even that would be hard to do safely (things could be done out of order), and it's just not necessary. And it would only be correct even then if the owner sent the tokens mistakenly. If somebody else did, you'd have to research and manually return them, etc. 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. We can also allow the owner to remove access tokens apart from the current ongoing reward, but we can remove this function from the campaign contract for the community's trust. |
||
TransferHelper.safeTransfer(tokenAddress, owner(), tokenAmount); | ||
} | ||
} |
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.
DiscountCampaign
seems a little misleading here, if I understand it correctly. To me it's more of a "rebate" campaign: you're earning incentives elsewhere, proportional to your swap volume. It's not giving you a discount on the swap itself (e.g., a lower swap fee) - which is also possible with hooks.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.
I would also add a lot more description here of how it works (or maybe a link to a page that explains it). For instance, you can run multiple campaigns with the same hook, but only one at a time.
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.
@Mubashir-ali-baig please see this one