APR Calculation
The Annual Percentage Rate (APR) maps an NFT's level to a base reward rate using a configurable polynomial. It is the primary reward driver — larger denominations (higher levels) earn proportionally more.
Polynomial Evaluation
The APR is computed via Polynomials.eval3(), a three-term polynomial evaluator that combines linear scaling with power-law exponentiation:
Where the four coefficients are stored as uint256[] in _apr[id]:
| Index | Name | Role | Default |
|---|---|---|---|
array[0] | add | Additive constant (also carries dynamic scalar) | 0 |
array[1] | div | Divisor (never zero) | 3 |
array[2] | mul | Multiplier (never zero) | 3{,}375{,}000 |
array[3] | exp | Exponent numerator (root fixed at 256) | 256 |
The full APR derivation in MoeTreasury.aprTargetOf() at MoeTreasury.sol:265-269:
function aprTargetOf(uint256 nftId, uint256[] memory array) private view returns (uint256) {
uint256 rate = Polynomial({array: array}).eval3(_ppt.levelOf(nftId));
uint256 base = Power.raise(1e6, array[array.length - 1]);
return Math.mulDiv(rate, 1e6, base);
}The base normalization divides by exp = 256, this simplifies to exp $\neq$ 256, the normalization adjusts the output scale to keep results in PPM.
The Power Library
Exponentiation is handled by Power.raise(n, exp) at Power.sol:10-15:
function raise(uint256 n, uint256 exp) internal pure returns (uint256) {
require(exp >= 128, InvalidExponentTooSmall(exp));
require(exp <= 512, InvalidExponentTooLarge(exp));
UD60x18 p = pow(ud(n * 1e18), ud(exp * 3906250e9));
return p.intoUint256() / 1e18;
}This computes pow() function. The key scaling:
The constant
Exponent Constraints
| Effective exponent | Interpretation | |
|---|---|---|
| 128 | Square root — concave | |
| 256 | Linear — proportional | |
| 384 | Convex — accelerating | |
| 512 | Quadratic — strongly accelerating |
Exponents below 128 would produce sub-square-root curves (impractical for reward design). Exponents above 512 risk overflow in 256-bit fixed-point arithmetic.
Accuracy
PRB Math's pow() operates on UD60x18 (60.18-bit fixed-point), providing 18 decimal digits of precision. The result is truncated to uint256 by flooring after division, introducing at most 1 wei of error per evaluation. The error is negligible for reward calculations where values are in PPM (
Parameter Space
Linear Configuration (Default)
With the simplified parameters [0, 256, 1, 256]:
The APR normalization then multiplies by
This is a deliberately conservative base rate — the actual deployed parameters scale values into the range where reward accumulation is meaningful over months and years.
Production Configuration
The deployed contract uses [0, 3, 3375000, 256]:
Normalized by
For level 30:
Concave, Linear, and Convex Curves
The add, div, mul, and exp parameters give the protocol broad control over rate curves:
Concave (Sub-linear)
[1000, 256, 50, 128] — square root shape, diminishing marginal returns:
Higher levels still earn more in absolute terms, but the rate of increase slows, favoring mid-tier staking.
Linear
[0, 256, 100, 256] — proportional:
Each additional level adds the same rate increment.
Convex (Super-linear)
[0, 256, 1, 512] — quadratic, accelerating returns:
Strongly incentivizes high-level staking; level 90 earns
APR by Level
Using the production parameters
| Level | As Percentage | |||
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0% |
| 9 | 9 | 10,125,000 | 10,125,000 | 10.125% |
| 21 | 21 | 23,625,000 | 23,625,000 | 23.625% |
| 30 | 30 | 33,750,000 | 33,750,000 | 33.750% |
| 45 | 45 | 50,625,000 | 50,625,000 | 50.625% |
| 60 | 60 | 67,500,000 | 67,500,000 | 67.500% |
| 90 | 90 | 101,250,000 | 101,250,000 | 101.250% |
Note: these are annualized rates. Actual rewards accrue per-second, so a stake held for 1 day at level 30 yields:
The CENTURY denominator (
Dynamic Scalar Integration
The additive constant array[0] is not static — it is recomputed by refreshRates() based on per-level share distribution:
_apr[id][0] = _scalar(APR_MUL, sum, bins, shares[i]);Where _scalar computes:
: total denomination staked across all active levels : number of active levels (non-zero shares) : denomination staked at level
When share is below the per-bin average, add exceeds mul, boosting the APR. When above average, add is reduced. Levels with zero shares get add = 0 (cleared) or add = 0, div = APR_DIV (default parameters).
This scalar folds into the APR calculation as:
When div is set to type(uint256).max (as refreshRates() does for active levels), the level-dependence is effectively disabled and only the scalar add matters — all levels at the same share level earn identical base rates modulated purely by the share distribution.
Rpp Constraints
Parameter changes via setAPR() are gated by Rpp.checkArray() and Rpp.checkValue():
Array Validation (Rpp.sol:13-19)
function checkArray(uint256[] memory array) internal pure {
require(array.length == 4, InvalidArrayLength(array.length));
require(array[1] > 0, InvalidArrayIndex(1)); // div non-zero
require(array[2] > 0, InvalidArrayIndex(2)); // mul non-zero
}Value Change Validation (Rpp.sol:24-34)
For both the per-level rate and the mean rate:
When transitioning from zero (last == 0), the new value must not exceed 1e6 (for rate) or APR_MUL (for mean):
function checkValue(uint256 next, uint256 last, uint256 unit) internal pure {
if (next < last) {
require(last <= 2 * next, TooSmall(next, last));
}
if (next > last && last > 0) {
require(next <= 2 * last, TooLarge(next, last));
}
if (next > last && last == 0) {
require(next <= unit, TooLarge(next, last));
}
}Timing Validation (Rpp.sol:39-43)
At least 1 month (2,628,000 seconds) must elapse between consecutive setAPR() calls for the same NFT id:
function checkStamp(uint256 next, uint256 last) internal pure {
if (last > 0) {
require(next > Constant.MONTH + last, TooQuick(next, last));
}
}The stamp is recorded as block.timestamp in the integrator's last item (accessed via the "S" meta key). This meta entry allows the integrator to track both the rate history and the governance-change history in the same data structure.
Invariants
- APR is always non-negative:
eval3operates on unsigned integers; the addend cannot be negative (Solidity uint256) - APR(0) = add: At level zero,
ratio = 0, soAPR(0) = Power.raise(add, exp)normalized - APR monotonicity: With
add ≥ 0,mul > 0,exp ≥ 128,for - Rate changes are time-weighted: The integrator smooths transitions; no instantaneous rate jumps affect existing stakers
- Only governance can change parameters:
setAPR()is gated byAPR_ROLE