APB Calculation
The Annual Percentage Bonus (APB) is a vintage premium that rewards stakers for holding their positions over extended periods. Unlike the APR, which depends on the NFT's level, the APB depends purely on how long the NFT has been staked — it is an additive loyalty bonus.
Polynomial Evaluation
The APB uses the same eval3 polynomial as the APR, but maps age in years rather than level:
Decomposed into the eval3 formula:
The full APB computation in MoeTreasury.apbTargetOf() at MoeTreasury.sol:481-490:
function apbTargetOf(uint256 nftId, uint256[] memory array) private view returns (uint256) {
uint256 nowYear = _ppt.year();
uint256 nftYear = _ppt.yearOf(nftId);
if (nowYear > nftYear || nowYear == nftYear) {
uint256 rate = Polynomial({array: array}).eval3(nowYear - nftYear);
uint256 base = Power.raise(1e6, array[array.length - 1]);
return Math.mulDiv(rate, 1e6, base);
}
return 0;
}The APB is zero if the NFT's year equals or exceeds the current year (i.e., NFT minted in the future or same year with no elapsed time).
Default Parameters
The deployed contract uses [0, 1, 100000, 256]:
| Index | Name | Value | Effect |
|---|---|---|---|
array[0] | add | 0 | No base bonus |
array[1] | div | 1 | No divisor scaling |
array[2] | mul | 100{,}000 | 100,000 PPM per year before exponent |
array[3] | exp | 256 | Linear exponent ( |
Plugging into eval3:
After normalization (multiply by
0.1% per year of stake duration, additive to APR. A 5-year stake at any level earns
Worked Example
A level-30 NFT staked for 3 years:
The APB contributes
Design Intent: Why APB Is Smaller Than APR
The APB is deliberately smaller than the APR by design:
Principal over patience: The protocol rewards capital commitment (denomination via level) more heavily than patience (duration). A level-60 NFT (
) dominates any feasible APB bonus through sheer denomination size. Linear accumulation: At 0.1% per year, the APB requires 10 years to reach 1%. This is intentionally slow — it prevents stakers from "gaming" the system by holding old, low-level NFTs that earn disproportionately high age bonuses.
Dynamic scalar separation: The APB is not scaled by share distribution. The dynamic scalar only affects APR (via the
addparameter). This keeps the APB as a pure, non-competitive incentive — every level earns the same APB per year regardless of market conditions.Non-compounding: APB is additive, not multiplicative. A 10-year stake earns
bonus, not . This simplicity keeps the reward computation gas-efficient.
Age Tracking
Age is tracked by the APowerNft contract using an accumulator pattern:
The _age Storage
mapping(address => mapping(uint256 => uint256)) private _age;The age of an NFT is not stored as a simple elapsed-time counter. Instead, _age is a timestamp-weighted accumulator:
When _age is zero (no stake), the computed age is zero (the ageOf() function returns 0 if _age == 0).
Mint: Starting the Clock
When an NFT is staked (minted in APowerNft), _pushMint() updates the accumulator:
function _pushMint(address account, uint256 nftId, uint256 amount) private {
_age[account][nftId] += amount * block.timestamp;
}After minting 1 unit at time
The age is correctly
Partial Unstaking: Proportional Reduction
When partially unstaking, _pushBurn() reduces _age proportionally:
function _pushBurn(address account, uint256 nftId, uint256 amount) private {
_age[account][nftId] -= Math.mulDiv(_age[account][nftId], amount, balanceOf(account, nftId));
}Let the current balance be
The remaining balance
Invariant (Average duration preservation): The average staking duration of the remaining position equals the average duration of the original position. A staker cannot "cherry-pick" newer or older portions by partially unstaking.
Proof sketch: The original average entry time is
Transfer: Age Reset
When an APowerNft is transferred via safeTransferFrom() or safeBatchTransferFrom(), the contract executes:
function safeTransferFrom(address account, address to, uint256 nftId, uint256 amount, bytes memory data)
public override nonReentrant
{
_pushBurn(account, nftId, amount);
_pushMint(to, nftId, amount);
super.safeTransferFrom(account, to, nftId, amount, data);
}The _pushBurn + _pushMint sequence effectively resets the age accumulator for the transferred tokens:
- Sender: Age accumulator is proportionally reduced (partial burn)
- Receiver: Age accumulator is set to
amount * block.timestamp(fresh entry at current time) - Claimed counter:
refreshClaimed()proportionally reduces the sender's claimed rewards so the receiver cannot re-claim
This means the receiver starts with age = 0 for the transferred tokens, eliminating any incentive to buy old positions purely for their accumulated APB. The transfer simultaneously calls _mty.refreshClaimed() to update claimed reward counters proportionally.
Years → Seconds Conversion
The APB operates on years (integers), not seconds. The year-of-NFT is derived from the NFT ID encoding:
function yearOf(uint256 nftId) public pure returns (uint256) {
return Nft.yearOf(nftId); // nftId / 100
}The current year is:
function year() public view returns (uint256) {
return Nft.year(); // (block.timestamp / 365.25 days) + 1970
}The age in years is
This coarse granularity (1-year steps) means the APB is stable and predictable — it does not fluctuate with sub-year timing and is immune to second-level manipulation.
Parameter Space
The same eval3 polynomial gives the APB design flexibility:
| Configuration | add | div | mul | exp | Shape |
|---|---|---|---|---|---|
| Default (linear) | 0 | 1 | 100,000 | 256 | |
| Flat + bump | 500,000 | 1 | 0 | 256 | |
| Concave | 0 | 256 | 25 | 128 | |
| Accelerating | 0 | 256 | 1 | 512 |
The concave configuration delivers most of the bonus in early years with diminishing returns thereafter — encouraging initial commitment without penalizing longer holds. The accelerating configuration strongly rewards multi-year positions.
APB Rate Smoothing
Like the APR, the APB uses the Integrator for time-weighted averaging. The integrator state is keyed by _apbId(nftId), which is fixed to a single bucket:
function _apbId(uint256) private view returns (uint256) {
return _ppt.idBy(2021, 3);
}All NFTs share the same APB integrator — a single rate curve applies universally. This reflects the design philosophy that loyalty is rewarded equally regardless of level.
The integrator for APB does not use the meta key ("" vs "S") — the setAPB() function appends without meta:
apbs[id].append(block.timestamp, currRate); // no metaRpp Constraints
setAPB() validates parameter changes similarly to setAPR():
- Array: Length 4, div and mul non-zero (via
Rpp.checkArray()) - Value:
for non-zero last - Stamp: At least 1 month between changes (via
Rpp.checkStamp())
Unlike APR, the APB only checks the rate value (not a mean), since there is no per-level variation to average over.
Invariants
- APB is always non-negative: eval3 outputs are unsigned; no negative bonuses
- APB(0) = add: A freshly staked NFT (0 years) earns only the additive constant
- APB is additive:
, never multiplicative - APB resets on transfer: Buyers of old positions receive no accumulated age benefit
- APB is level-independent: Same APB per year regardless of NFT level
- Only governance can change parameters:
setAPB()is gated byAPB_ROLE