Skip to content

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:

eval3(x,[a0,a1,a2,a3])=(x×a2a1+a0)a3/256

Where the four coefficients are stored as uint256[] in _apr[id]:

IndexNameRoleDefault
array[0]addAdditive constant (also carries dynamic scalar)0
array[1]divDivisor (never zero)3
array[2]mulMultiplier (never zero)3{,}375{,}000
array[3]expExponent numerator (root fixed at 256)256

The full APR derivation in MoeTreasury.aprTargetOf() at MoeTreasury.sol:265-269:

solidity
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 Power.raise(106,exp)=(106)exp/256. When exp = 256, this simplifies to 106, and the normalization has no net effect (multiply and divide cancel). For 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:

solidity
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 nexp/256 using PRB Math's pow() function. The key scaling:

raise(n,e)=pow(n×1018,e×3.906250×109)1018

The constant 3,906,250×109=1256×1018 converts the exponent from a numerator-over-256 representation to UD60x18 fixed-point. The root is always 256 — the exponent numerator e determines the actual power.

Exponent Constraints

128e512
eEffective exponentInterpretation
128n0.5Square root — concave
256n1.0Linear — proportional
384n1.5Convex — accelerating
512n2.0Quadratic — 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 (106 scale).

Parameter Space

Linear Configuration (Default)

With the simplified parameters [0, 256, 1, 256]:

ratio=x×1256=x256result=Power.raise(x256+0,256)=(x256)256/256=x256

The APR normalization then multiplies by 106 and divides by raise(106,256)=106, giving an effective APR of x/256 PPM. Level 30 yields:

APR(30)=30256 PPM0.117 PPM=0.0000117%

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]:

ratio=x×3,375,0003=x×1,125,000result=Power.raise(x×1,125,000,256)=x×1,125,000

Normalized by 106:

APR(x)=x×1,125,000×106106=x×1,125,000 PPM

For level 30: APR(30)=33,750,000 PPM=33.75% annual rate times age fraction.

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:

APR(x)50x256+1000

Higher levels still earn more in absolute terms, but the rate of increase slows, favoring mid-tier staking.

Linear

[0, 256, 100, 256] — proportional:

APR(x)=100x256 PPM

Each additional level adds the same rate increment.

Convex (Super-linear)

[0, 256, 1, 512] — quadratic, accelerating returns:

APR(x)=(x256)2 PPM

Strongly incentivizes high-level staking; level 90 earns 81× what level 10 earns (relative to a linear curve).

APR by Level

Using the production parameters [0,3,3,375,000,256], the raw eval3 output and normalized APR for selected levels:

Leveleval3()APR() (PPM)As Percentage
00000%
9910,125,00010,125,00010.125%
212123,625,00023,625,00023.625%
303033,750,00033,750,00033.750%
454550,625,00050,625,00050.625%
606067,500,00067,500,00067.500%
9090101,250,000101,250,000101.250%

Note: these are annualized rates. Actual rewards accrue per-second, so a stake held for 1 day at level 30 yields:

R=33,750,000×86,400×1048106×3,155,760,000(small fraction of denomination)

The CENTURY denominator (3,155,760,000 seconds) spreads the annual rate across ~100 years of seconds, making per-day rewards tiny in percentage terms but meaningful for large denominations over long durations.

Dynamic Scalar Integration

The additive constant array[0] is not static — it is recomputed by refreshRates() based on per-level share distribution:

solidity
_apr[id][0] = _scalar(APR_MUL, sum, bins, shares[i]);

Where _scalar computes:

add=mul×sumbins×share
  • sum: total denomination staked across all active levels
  • bins: number of active levels (non-zero shares)
  • share: 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:

APR()×muldiv+add

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)

solidity
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:

0.5nextlast2.0

When transitioning from zero (last == 0), the new value must not exceed 1e6 (for rate) or APR_MUL (for mean):

solidity
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:

solidity
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

  1. APR is always non-negative: eval3 operates on unsigned integers; the addend cannot be negative (Solidity uint256)
  2. APR(0) = add: At level zero, ratio = 0, so APR(0) = Power.raise(add, exp) normalized
  3. APR monotonicity: With add ≥ 0, mul > 0, exp ≥ 128, APR(2)APR(1) for 2>1
  4. Rate changes are time-weighted: The integrator smooths transitions; no instantaneous rate jumps affect existing stakers
  5. Only governance can change parameters: setAPR() is gated by APR_ROLE