Compota is an ERC20 token designed to continuously accrue rewards for its holders. These rewards come in two main forms:
- Base Rewards: Simply holding the token yields automatically accruing interest over time.
- Staking Rewards: Staking Uniswap V2-compatible liquidity pool (LP) tokens for additional yield, boosted by a time-based cubic multiplier applied to the staking rewards formula.
The system also introduces key features such as configurable interest rate bounds, a reward cooldown to prevent excessive compounding, a maximum total supply cap, multi-pool staking, and thorough ownership controls. This README explains every novel element, mathematical underpinning, usage flow, and test coverage in great detail.
- Conceptual Overview
- Feature Highlights
- Contract Architecture
- Mathematical Foundations of Rewards
- Implementation Details
- Why Uniswap V2?
- Ownership & Access Control
- Overridden ERC20 Methods
- API Reference & Methods
- Security & Audit Considerations
- License
Compota
is a system that auto-accrues yield for holders while also allowing stakers to earn boosted returns. The boost is governed by a cubic multiplier that scales rewards significantly the longer the staker remains in the pool. Additionally, the system is designed with predictable rate changes (bounded by min/max BPS), a reward cooldown to prevent abuse via rapid re-claims, and a maximum total supply that caps inflation.
- Continuous Accrual: Rewards accumulate over time, without constant user claims.
- Cubic Multiplier: Novel time-based booster for stakers, culminating in higher returns for longer durations.
- Multi-Pool Staking: Supports multiple LP tokens, each with distinct parameters.
- Configurable Rate Bounds: An owner can adjust the yearlyRate (APR in BPS) within
[MIN_YEARLY_RATE, MAX_YEARLY_RATE]
. - Reward Cooldown: Users must wait a specified period to claim new rewards, preventing over-compounding.
- Max Total Supply: Prevents unbounded inflation.
Compota
inherits from:
- ERC20Extended: A standard token interface (with 6 decimals) plus minor utility methods:
- We use the M0 standard ERC20Extended because it incorporates additional functionality beyond the standard ERC20, including EIP-2612 for signed approvals (via EIP-712, with compatibility for EIP-1271 and EIP-5267) and EIP-3009 for transfers with authorization (also using EIP-712). This makes the token more versatile and compatible with modern cryptographic signing standards, improving user experience and flexibility.
- Owned: An ownership module from Solmate controlling certain admin functions.
It interfaces with:
- ICompota: The main external interface.
- IERC20
- IUniswapV2Pair: For reading pool reserves (
getReserves()
) and identifying token addresses, ensuring Uniswap v2 compatibility.
Data structures central to the system:
AccountBalance
: Tracks base holdings for each user.UserStake
: Tracks staked LP and relevant timestamps.StakingPool
: Parameters for each pool, includinglpToken
,multiplierMax
,timeThreshold
.
For base rewards, each address’s holding grows according to:
Δ_base = (avgBalance * elapsedTime * yearlyRate) / (SCALE_FACTOR * SECONDS_PER_YEAR)
where:
- avgBalance is the user’s time-weighted average holdings,
- elapsedTime is the number of seconds since last update,
- yearlyRate is in BPS,
- SCALE_FACTOR = 10,000,
- SECONDS_PER_YEAR = 31,536,000.
When staking an LP token, the user’s effective portion of Compota
in the pool is determined by:
compotaPortion = (avgLpStaked * compotaReserve) / lpTotalSupply
The staking reward itself (Δ_staking) applies the cubic multiplier in the final step:
Δ_staking = (compotaPortion * elapsedTime * yearlyRate) / (SCALE_FACTOR * SECONDS_PER_YEAR) * cubicMultiplier(t)
A core innovation is the cubic multiplier for staking. Let:
- t = timeStaked
- timeThreshold
- multiplierMax (scaled by 1e6)
Then:
cubicMultiplier(t) = multiplierMax if t >= timeThreshold
cubicMultiplier(t) = 1*10^6 + (multiplierMax - 10^6) * (t / timeThreshold)^3 if t < timeThreshold
By using an average balance rather than a single snapshot, Compota fairly accounts for both the amount of tokens a user holds (or stakes) and how long they hold them. If only an instantaneous balance was measured, users could briefly inflate their balance right before a snapshot to gain disproportionate rewards. Meanwhile, those consistently holding or staking a moderate balance over a longer period would be undercompensated. The time-weighted average ensures that each user’s reward is proportional not just to the magnitude of their balance, but also to the duration they keep it, reflecting a more accurate and equitable distribution of yield.
To compute average balance, the contract uses discrete integration at every balance-changing event (transfer, stake, unstake, claim).
- Accumulate:
accumulatedBalancePerTime += (balance * (T_now - lastUpdateTimestamp))
lastUpdateTimestamp = T_now
- Average Balance:
avgBalance = accumulatedBalancePerTime / (T_final - periodStartTimestamp)
This yields a time-weighted average of how much the user held or staked.
- The contract holds an array of
StakingPool
. - Each pool has its own LP token,
multiplierMax
, andtimeThreshold
. - Users can stake/unstake by specifying
poolId
.
MIN_YEARLY_RATE
andMAX_YEARLY_RATE
define the allowable range.- Attempts to set
yearlyRate
outside this range revert.
- A global
rewardCooldownPeriod
ensures a user cannot claim rewards too often, preventing over-compounding. - If a user attempts to claim before cooldown finishes, only their internal accounting is updated.
- Any token mint or reward mint cannot exceed
maxTotalSupply
. - If a reward calculation attempts to exceed the supply cap, it is truncated.
- Maintains global timestamps plus user-specific data (
AccountBalance
,UserStake
). - Ensures each user’s pending rewards are accurately tracked and minted only if cooldown passes.
- Tracks stakers in an
activeStakers
array +_activeStakerIndices
mapping. - Users are removed from the list when they fully unstake from all pools.
- Uses
uint224
to avoid overflow. - BPS calculations are scaled by
10,000
, multipliers by1e6
. - Casting is checked with
toSafeUint224
.
- Custom errors like
InvalidYearlyRate
,NotEnoughStaked
,InsufficientAmount
give precise revert reasons. - Events like
YearlyRateUpdated
,RewardCooldownPeriodUpdated
ensure transparency.
The Compota contract is designed to work with Uniswap V2-compatible liquidity pools for the following reasons:
-
Simplicity and Compatibility:
Uniswap V2 provides a straightforward mechanism to retrieve pool reserves via thegetReserves()
function. This allows the contract to calculate theCompota
portion in the pool with minimal complexity, ensuring efficient and reliable reward calculations. -
Standardization:
The V2 interface is widely adopted and integrated across various DeFi ecosystems. By relying on this standard, Compota ensures compatibility with most decentralized exchanges and LP tokens available today. -
Future-Proofing with Uniswap V4:
While Uniswap V4 introduces new features and changes, its flexibility allows pools to be adapted to emulate V2 behavior. For example, projects like V2PairHook demonstrate how V4 pools can be wrapped to mimic V2 interfaces. This ensures that Compota will remain compatible with future developments in Uniswap. -
Efficiency in Reserve Calculations:
The reserve-based calculations in V2 are straightforward and require minimal on-chain processing, making them gas-efficient. This aligns with Compota’s goal of delivering robust rewards mechanisms while keeping costs manageable for users.
By leveraging Uniswap V2 compatibility, Compota ensures a balance between current usability and adaptability to future innovations, making it a solid choice for staking and reward distribution.
- Inherits
Owned
from Solmate. - Only the
owner
has the privilege to:- Set
yearlyRate
: Adjust the annual percentage yield within the min/max range. - Set
rewardCooldownPeriod
: Define the cooldown period for claiming rewards. - Add staking pools: Introduce new liquidity pool options with custom parameters.
- Mint new tokens: Create additional tokens, respecting the
maxTotalSupply
constraint.
- Set
- Non-owners cannot perform these privileged actions.
The Compota contract customizes several standard ERC20 methods to incorporate rewards logic and enforce system constraints:
-
balanceOf(address account)
:- Returns the current token balance, including unclaimed base and staking rewards.
- This ensures users see their effective balance at all times.
-
totalSupply()
:- Dynamically calculates the total supply, including all pending unclaimed rewards across accounts.
- Enforces the
maxTotalSupply
constraint.
-
_transfer(address sender, address recipient, uint256 amount)
:- Updates reward states for both the sender and the recipient before executing the token transfer.
- Maintains accurate reward calculations for all involved parties.
-
_mint(address to, uint256 amount)
:- Ensures the
maxTotalSupply
constraint is respected during minting. - Updates internal reward states when minting tokens.
- Ensures the
-
_burn(address from, uint256 amount)
:- Verifies sufficient balance (including pending rewards) before burning tokens.
- Adjusts internal balances and the total supply accordingly.
These overrides ensure that reward logic and supply constraints are seamlessly integrated into ERC20 operations without disrupting compatibility.
-
setYearlyRate(uint16 newRate_)
Adjusts APY (BPS) within[MIN_YEARLY_RATE, MAX_YEARLY_RATE]
. -
setRewardCooldownPeriod(uint32 newRewardCooldownPeriod_)
Changes the cooldown for claiming. -
addStakingPool(address lpToken_, uint32 multiplierMax_, uint32 timeThreshold_)
Introduces a new pool. -
stakeLiquidity(uint256 poolId_, uint256 amount_)
Stakes LP tokens inpoolId_
. -
unstakeLiquidity(uint256 poolId_, uint256 amount_)
Unstakes LP tokens frompoolId_
. -
mint(address to_, uint256 amount_)
Owner-only. RespectsmaxTotalSupply
. -
burn(uint256 amount_)
Burns user’s tokens. -
balanceOf(address account_) returns (uint256)
Current user balance + unclaimed rewards. -
calculateBaseRewards(address account_, uint32 currentTimestamp_) returns (uint256)
Helper function for base reward math. -
calculateStakingRewards(address account_, uint32 currentTimestamp_) returns (uint256)
Helper function for staking reward math. -
totalSupply() returns (uint256)
Global supply including pending rewards. -
claimRewards()
Mints pending rewards if cooldown is met; otherwise updates state.
calculateCubicMultiplier(uint256 multiplierMax_, uint256 timeThreshold_, uint256 timeStaked_) returns (uint256)
Public helper to view the multiplier growth.
- Ownership: The
owner
can change rates, add pools, and mint tokens—adopt secure governance (e.g., multisig). - Time Manipulation: Miners can nudge block timestamps slightly, but the contract’s design minimizes material impact over long durations.
- Rate Boundaries: Constraining the APY within
[MIN_YEARLY_RATE, MAX_YEARLY_RATE]
prevents extreme or sudden changes. - Cooldown Enforcement: Thwarts repeated reward claims, limiting excessive compounding exploitation.
- Max Supply: Caps total token issuance to prevent runaway inflation.
- Uniswap v2: Straightforward reserve interface. For v4, consider this wrapper approach.
All code in Compota.sol
and associated files is published under the GPL-3.0 license.
For full details, see the LICENSE file.
🍎✨ Dive into the sweet world of Compota—where your rewards grow continuously! ✨🍐
The easiest way to get started is by clicking the Use this template button at the top right of this page.
If you prefer to go the CLI way:
forge init my-project --template https://github.com/MZero-Labs/foundry-template
You may have to install the following tools to use this repository:
- Foundry to compile and test contracts
- lcov to generate the code coverage report
- slither to static analyze contracts
Install dependencies:
npm i
Copy .env
and write down the env variables needed to run this project.
cp .env.example .env
Run the following command to compile the contracts:
npm run compile
Forge is used for coverage, run it with:
npm run coverage
You can then consult the report by opening coverage/index.html
:
open coverage/index.html
To run all tests:
npm test
Run test that matches a test contract:
forge test --mc <test-contract-name>
Test a specific test case:
forge test --mt <test-case-name>
To run slither:
npm run slither
Husky is used to run lint-staged and tests when committing.
Prettier is used to format code. Use it by running:
npm run prettier
Solhint is used to lint Solidity files. Run it with:
npm run solhint
To fix solhint errors, run:
npm run solhint-fix
The following Github Actions workflow are setup to run on push and pull requests:
It will build the contracts and run the test coverage, as well as a gas report.
The coverage report will be displayed in the PR by github-actions-report-lcov and the gas report by foundry-gas-diff.
For the workflows to work, you will need to setup the MNEMONIC_FOR_TESTS
and MAINNET_RPC_URL
repository secrets in the settings of your Github repository.
Some additional workflows are available if you wish to add fuzz, integration and invariant tests:
- .github/workflows/test-fuzz.yml
- .github/workflows/test-integration.yml
- .github/workflows/test-invariant.yml
You will need to uncomment them to activate them.
The documentation can be generated by running:
npm run doc
It will run a server on port 4000, you can then access the documentation by opening http://localhost:4000.
To compile the contracts for production, run:
npm run build
Open a new terminal window and run anvil to start a local chain:
anvil
Deploy the contracts by running:
npm run deploy-local
To deploy to the Sepolia testnet, run:
npm run deploy-sepolia