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

BAL Hookathon - Swap Bond Hook #89

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5a81e1d
wip: basic discount functionality
anassohail99 Sep 11, 2024
b5ae743
wip: swap discount hook
anassohail99 Sep 19, 2024
7bc8720
Merge branch 'balancer:main' into dev
anassohail99 Sep 20, 2024
4732434
update contract inheritance
anassohail99 Sep 20, 2024
4114108
update hook to new architecture
anassohail99 Sep 24, 2024
3153b2a
wip: campaign contract
anassohail99 Sep 24, 2024
0bed770
wip: discount campaign contract
anassohail99 Sep 25, 2024
4c2d05f
add check for campaign creation
anassohail99 Sep 25, 2024
00e6692
update test cases
anassohail99 Sep 25, 2024
64dbba6
add natspec
anassohail99 Sep 25, 2024
5040636
updated contract architecture
anassohail99 Sep 26, 2024
a762685
updated architecture
anassohail99 Sep 26, 2024
e7a2186
add ercRecover
anassohail99 Sep 26, 2024
12ed1c7
add updater for campaign details
anassohail99 Sep 26, 2024
9b2ad52
optimize factory contract
anassohail99 Sep 26, 2024
e83c6bc
Merge pull request #1 from a51finance/swap-hook
anassohail99 Sep 26, 2024
c28366a
add safe transfer
anassohail99 Sep 27, 2024
e5dd837
wip
anassohail99 Oct 1, 2024
0fbd367
add test cases
anassohail99 Oct 2, 2024
a0bbb3c
add test cases
anassohail99 Oct 2, 2024
d9aaaa3
update natspec
anassohail99 Oct 2, 2024
ea61ec7
Merge pull request #2 from a51finance/swap-hook
anassohail99 Oct 2, 2024
936b155
update test cases
anassohail99 Oct 3, 2024
29c333a
fix logic for cool down period
anassohail99 Oct 3, 2024
30e8787
Merge pull request #3 from a51finance/swap-hook
anassohail99 Oct 4, 2024
3a45cd3
refactore code and testcases
anassohail99 Oct 4, 2024
22c9fd1
Merge pull request #4 from a51finance/swap-hook
anassohail99 Oct 4, 2024
ef00404
Refactor and Readme Formatting
Mubashir-ali-baig Oct 4, 2024
9aef2bb
README Formatting
Mubashir-ali-baig Oct 4, 2024
dcba084
README Formatting
Mubashir-ali-baig Oct 4, 2024
23ae8ea
README Formatting
Mubashir-ali-baig Oct 4, 2024
689947c
README Formatting
Mubashir-ali-baig Oct 4, 2024
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
node_modules

bin
# dependencies, yarn, etc
# yarn / eslint
.yarn/*
Expand Down
2 changes: 0 additions & 2 deletions packages/foundry/.env.example

This file was deleted.

212 changes: 212 additions & 0 deletions packages/foundry/contracts/hooks/SwapDiscountHook/DiscountCampaign.sol
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 {

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.

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.

Copy link
Author

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

/// @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;

Choose a reason for hiding this comment

The 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 swapAmountCap or something similar?

Copy link
Author

Choose a reason for hiding this comment

The 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;

Choose a reason for hiding this comment

The 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 _maxDiscountPercentage, and define it as an 18-decimal FixedPoint value from 0-1. (Here it seems to be used assuming 0-100.)

Copy link
Author

Choose a reason for hiding this comment

The 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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nit, but _variableName is for private state variables. When there's a name conflict and we need to alter the name in the signature, we've typically used variableName_ or variableNameParam

Copy link
Author

Choose a reason for hiding this comment

The 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) {

Choose a reason for hiding this comment

The 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 _checkAndAuthorizeTokenId function.

Copy link
Author

Choose a reason for hiding this comment

The 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;

Choose a reason for hiding this comment

The 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 claimableReward = swappedAmount.mulDown(campaignDetails.discountRate).

Copy link
Author

Choose a reason for hiding this comment

The 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 {

Choose a reason for hiding this comment

The 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.

Copy link
Author

Choose a reason for hiding this comment

The 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);
}
}
Loading