Skip to main content

In Part 1 of this series, we explored the concept of hooks within the Balancer protocol, looked at its architecture, and discussed the possibilities they open up for developers.

Now, in Part 2, we’ll take a practical approach by introducing LoyaltyHook – a Balancer V3 hook designed to increase user engagement through a tokenized loyalty program.

LoyaltyHook overview

LoyaltyHook is a Balancer V3 hook that rewards users with loyalty tokens, called LOYALTY, when they interact with a pool. Whether users add liquidity or perform swaps, they earn LOYALTY tokens proportional to their activity.

These tokens not only serve as a reward, but also unlock benefits such as reduced swap and exit fees based on the user’s LOYALTY balance. The more a user participates in the pool, the more they are rewarded, encouraging consistent participation.

By integrating LoyaltyHook into a Balancer pool, liquidity providers and traders are incentivized to remain active, increasing the pool’s liquidity and trading volume. It’s a proactive approach to building a loyal user base and improving the overall ecosystem.

Main features

In order to incentivize user engagement, LoyaltyHook offers several key features:

Loyalty tokens

Loyalty tokens are the tokenized incentive program:

  • Earning rewards: Users earn LOYALTY tokens when they add liquidity to the pool or perform swaps within the pool.
  • Proportional minting: The amount of LOYALTY tokens minted is proportional to the value of the transaction (liquidity provided or swap size).
  • Decay mechanism: To prevent excessive rewards, a decay factor reduces the amount of tokens minted per action over time, encouraging consistent engagement.

Dynamic swap fees

  • Fee adjustments: Swap fees are dynamically adjusted based on the user’s LOYALTY balance.
  • Greater discounts: Higher LOYALTY balances result in greater fee discounts.
  • Encourage retention: Users are incentivised to accumulate and retain LOYALTY tokens to benefit from lower fees.

Implemented in onComputeDynamicSwapFeePercentage:

function onComputeDynamicSwapFeePercentage(
    PoolSwapParams calldata params,
    address pool,
    uint256 staticSwapFeePercentage
    ) public view override onlyVault returns (bool success, uint256 dynamicSwapFeePercentage)

Exit fees

  • Fee application: An exit fee is applied when users withdraw liquidity to discourage frequent withdrawals.
  • Loyalty discounts: Users with sufficient LOYALTY tokens will receive discounts on exit fees. At higher LOYALTY balances levels, exit fees can be significantly reduced or even waived.

Implemented in onAfterSwap:

function onAfterSwap(
    AfterSwapParams calldata params
) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) {

Action tracking and decay mechanism

In order to prevent rewarded activities (e.g. trades) from being broken up into smaller chunks in order to multiply LOYALTY rewards, the rewards decrease with each subsequent action.

  • Action count: Tracks the number of actions (swaps or liquidity provisions) performed by a user.
  • Decay factor: Adjusts the reward rate over time to encourage sustained engagement rather than sporadic bursts.
  • Reset interval: Action counts are reset after a period of time (e.g. 30 days), allowing users to regain higher reward rates.

Tracked by below variables:

// Mapping to track the number of actions per user
mapping(address => uint256) public userActionCount;

// Mapping to track the action reset per user
mapping(address => uint256) public lastActionReset;

// Reset decay after set interval
uint256 public resetInterval = 30 days;

As you remember from part 1, in order to implement the above features, we need to set certain flags in a hook implementation – this is how it looks in LoyaltyHook.

function getHookFlags() public pure override returns (HookFlags memory) {
    HookFlags memory hookFlags;
    // apply swap fee discounts
    hookFlags.shouldCallComputeDynamicSwapFee = true; 
    // mint LOYALTY after swap
    hookFlags.shouldCallAfterSwap = true; 
    // must be true for hooks to modify liquidity or swap operations in "after hooks"
    hookFlags.enableHookAdjustedAmounts = true;
    // apply exit fees after removing liquidity
    hookFlags.shouldCallAfterRemoveLiquidity = true; 
    // mint LOYALTY after adding liquidty
    hookFlags.shouldCallAfterAddLiquidity = true; 
    return hookFlags;
}

Architecture

LoyaltyHook uses a strategy pattern that allows developers to create their own incentive mechanism. This design allows for flexibility in adjusting reward calculations and fee structures without changing the core hook contract.

Let’s take a look at the four main components:

LoyaltyHook contract

The core contract that extends the pool functionality with loyalty incentive mechanisms. LoyaltyHook delegates calculation tasks to the ILoyaltyRewardStrategy implementations.

Key responsibilities:

  • Tracks user actions and maintains action counts.
  • Mints LOYALTY tokens based on user interactions.
  • Dynamically adjusts exchange and exit fees.
  • Delegates calculations to ILoyaltyRewardStrategy.
  • Access control: Grants the MINTER_ROLE in the LoyaltyToken contract to mint tokens.

Full code: https://github.com/Chainkraft/scaffold-balancer-v3/blob/LoyaltyHook/packages/foundry/contracts/hooks/LoyaltyHook.sol

ILoyaltyRewardStrategy Interface

Defines the methods that any loyalty reward strategy must implement. This ensures that the LoyaltyHook can interact with any strategy that adheres to this interface, promoting modularity and extensibility.

interface ILoyaltyRewardStrategy {
    function calculateDiscountedFeePercentage(
        uint256 baseFeePercentage,
        uint256 loyaltyBalance
    ) external view returns (uint256 discountedFeePercentage);

    function calculateExitFees(
        uint256[] memory baseAmounts,
        uint256 exitFeePercentage,
        uint256 loyaltyBalance
    ) external view returns (uint256[] memory adjustedAmounts, uint256[] memory accruedFees);

    function calculateMintAmount(
      uint256 baseAmount, 
      uint256 actionCount
    ) external view returns (uint256 mintAmount);
}

LoyaltyRewardStrategy contract

Implements the ILoyaltyRewardStrategy interface and contains the logic for:

  • Calculating discounted fees based on LOYALTY balance.
  • Determining the amount of LOYALTY tokens to mint.
  • Computing exit fees with loyalty-based discounts.

Using the strategy pattern, different reward strategies can be swapped in without modifying the LoyaltyHook contract.

Full code: https://github.com/Chainkraft/scaffold-balancer-v3/blob/LoyaltyHook/packages/foundry/contracts/hooks/strategies/LoyaltyRewardStrategy.sol

The implementation of LoyaltyRewardStrategy uses a tiered approach to determine fee discounts based on a user’s LOYALTY token holdings. The implemented strategy includes the following tiers

Tier 1:

  • Threshold: ≥ 100 LOYALTY tokens
  • Discount: 50% off swap and exit fees

Tier 2:

  • Threshold: ≥ 500 LOYALTY tokens
  • Discount: 80% discount on swap and exit fees

Tier 3:

  • Threshold: ≥ 1000 LOYALTY tokens
  • Discount: 90% off swap and exit fees

These tiers encourage users to accumulate LOYALTY tokens to benefit from greater discounts.

LoyaltyToken contract

An ERC20 token contract that represents the LOYALTY tokens rewarded to users. It incorporates OpenZeppelin’s AccessControl for role-based permissions.

  • Minting capability: Allows minting of new tokens.
  • Access control:
    • Uses AccessControl to manage roles.
    • Defines a MINTER_ROLE for entities allowed to mint tokens.
    • The LoyaltyHook contract is granted the MINTER_ROLE to mint LOYALTY tokens to users.
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract LoyaltyToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    // Function to grant minter role (can only be called by admin)
    function grantMinterRole(address minter) public onlyRole(DEFAULT_ADMIN_ROLE) {
        grantRole(MINTER_ROLE, minter);
    }

    // Function to revoke minter role (can only be called by admin)
    function revokeMinterRole(address minter) public onlyRole(DEFAULT_ADMIN_ROLE) {
        revokeRole(MINTER_ROLE, minter);
    }
}

Testing

Ensuring the reliability and accuracy of the smart contract implementation is essential. To achieve this, we implemented tests to validate the functionality of the contracts under different scenarios. The test suite is implemented using Foundry in the LoyaltyHookTest contract.

Test covers a wide range of scenarios to ensure that the LoyaltyHook behaves as expected. Below are the key areas that were thoroughly tested:

  • Swap operations:
    • Swaps without any LOYALTY balance to ensure standard fees apply.
    • Swaps with varying LOYALTY balances to verify fee discounts are correctly applied.
    • Verification of correct token balances before and after swaps.
  • Liquidity provision:
    • Adding liquidity and verifying that LOYALTY tokens are minted appropriately.
    • Testing the decay mechanism by performing multiple liquidity additions and checking the reduced minting amounts.
  • Exit fees:
    • Removing liquidity and ensuring that exit fees are calculated based on LOYALTY balances.
    • Confirming that the exit fees are correctly distributed back to the pool or LPs.
  • Action count reset:
    • Testing that the action count for users resets after the specified interval (e.g., 30 days).
    • Ensuring that after the reset, users receive full minting amounts without accumulated decay.

Full code: https://github.com/Chainkraft/scaffold-balancer-v3/blob/LoyaltyHook/packages/foundry/test/LoyaltyHook.t.sol

If you are working on contracts built on top of Balancer v3, remember the base test classes – they make your life A LOT easier by providing a lot of handy helper methods. BaseVaultTest with fake users and their balances is a real life saver – you can provide liquidity, swap and do anything a normal user would do in your code and test user behaviour.

Sample use case

Let’s consider a practical scenario involving Alice, an active trader and liquidity provider within the pool that has LoyaltyHook integrated.

  1. Adding liquidity:
    • Alice adds liquidity to the pool.
    • The LoyaltyHook mints LOYALTY tokens to her account in proportion to the amount of liquidity she has added.
    • Her action count increases, and the decay mechanism adjusts her rewards appropriately.
  2. Performing swaps:
    • Alice swaps tokens within the pool.
    • She earns additional LOYALTY tokens for each swap in proportion to the amount she has swapped.
    • Her growing LOYALTY balance moves her up the loyalty tiers.
  3. Receiving fee discounts:
    • As her LOYALTY balance surpasses tier thresholds, she enjoys reduced swap and exit fees.
    • At Tier 2, she gets an 80% discount, significantly lowering her trading costs.
  4. Removing liquidity:
    • When Alice decides to withdraw her liquidity, her high LOYALTY balance grants her reduced or waived exit fees.
    • This maximizes her returns and encourages her to continue participating in the future.

Benefits for Alice:

  • Lower fees: Enhances her profitability by reducing trading and exit costs.
  • Earning rewards: Adds value to her participation through LOYALTY tokens.
  • Incentivized engagement: Aligns incentives with her interest in staying active within the pool.

Roadmap

While the initial implementation of LoyaltyHook focuses on incentivising user engagement within a liquidity pool, the architecture of tokenized loyalty program allows for some future enhancements:

  • Staking mechanisms: Allow users to stake LOYALTY tokens to earn additional rewards or exclusive benefits.
  • Governance participation: Allow LOYALTY holders to participate in pool governance decisions, such as adjusting fees or reward parameters.
  • Cross-platform Integrations: Extend the value of LOYALTY tokens by integrating with other DeFi platforms or protocols.
  • Gamification elements: Introduce milestones, badges or leaderboards to further incentivise user engagement.
  • Custom reward strategies: Develop new strategies that are easy to integrate thanks to the modular design.
  • Take advantage of more v3 features: Balancer v3 introduces a lot of new mechanisms that could be explored and tied to tokenised rewards, e.g. we could explore the relationship between LOYALTY price and PoolCreatorFee -> that could serve as a backing for a loyalty programme.

Conclusion

Our LoyaltyHook project, submitted for the Balancer Hookathon, demonstrates just a portion of what the Balancer V3 hook framework can achieve. By introducing a tokenized loyalty mechanism, we demonstrated how hooks can be effectively used to increase user engagement and add meaningful incentives within DeFi pools.

Balancer V3’s hook framework provides developers with extensive opportunities to customize and extend pool functionality, from dynamic fee adjustments to custom governance solutions and much more. While our focus was on the LoyaltyHook due to project and time constraints, the potential applications of the hooks are vast and varied. We invite you to experiment with these hooks, realize their full potential, and contribute to the ongoing innovation of the Balancer ecosystem.

If you need any assistance or have questions about implementing Balancer hooks, feel free to reach out to us!


Thank you for joining us for this two-part series on building a custom Balancer hook and exploring our Balancer Hookathon entry. I hope you’ve enjoyed reading it as much as we’ve enjoyed coding it.