Skip to content

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:

APB(years)=eval3(ageInYears,[add,div,mul,exp])

Decomposed into the eval3 formula:

APB(y)=(y×a2a1+a0)a3/256

The full APB computation in MoeTreasury.apbTargetOf() at MoeTreasury.sol:481-490:

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

IndexNameValueEffect
array[0]add0No base bonus
array[1]div1No divisor scaling
array[2]mul100{,}000100,000 PPM per year before exponent
array[3]exp256Linear exponent (exp/256=1.0)

Plugging into eval3:

ratio=y×100,0001=y×100,000result=Power.raise(y×100,000,256)=y×100,000

After normalization (multiply by 106, divide by raise(106,256)=106):

APB(y)=y×100,000 PPM=y×0.1=0.1y

0.1% per year of stake duration, additive to APR. A 5-year stake at any level earns APB(5)=0.5 (i.e., 500,000 PPM).

Worked Example

A level-30 NFT staked for 3 years:

APR(30)=30×1,125,000=33,750,000 PPMAPB(3)=3×100,000=300,000 PPMTotal rate=33,750,000+300,000=34,050,000 PPM

The APB contributes 300,00034,050,0000.88% of the total rate for this position — small but steadily growing.

Design Intent: Why APB Is Smaller Than APR

The APB is deliberately smaller than the APR by design:

  1. Principal over patience: The protocol rewards capital commitment (denomination via level) more heavily than patience (duration). A level-60 NFT (D=1060) dominates any feasible APB bonus through sheer denomination size.

  2. 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.

  3. Dynamic scalar separation: The APB is not scaled by share distribution. The dynamic scalar only affects APR (via the add parameter). This keeps the APB as a pure, non-competitive incentive — every level earns the same APB per year regardless of market conditions.

  4. Non-compounding: APB is additive, not multiplicative. A 10-year stake earns 10×0.1%=1% bonus, not (1.001)1011.004%. This simplicity keeps the reward computation gas-efficient.

Age Tracking

Age is tracked by the APowerNft contract using an accumulator pattern:

The _age Storage

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

age(account,nftId)=balance×block.timestamp_age

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:

solidity
function _pushMint(address account, uint256 nftId, uint256 amount) private {
    _age[account][nftId] += amount * block.timestamp;
}

After minting 1 unit at time t0:

_age=t0,age=1×tt0=tt0

The age is correctly tt0, the elapsed seconds since staking.

Partial Unstaking: Proportional Reduction

When partially unstaking, _pushBurn() reduces _age proportionally:

solidity
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 B and the burn amount be AB. Then:

_age=_age_age×AB=_age×(1AB)

The remaining balance BA has age:

age=(BA)×t_age×(1AB)

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 t¯0=_age/B. After burning A tokens:

t¯0=_ageBA=_age×(1A/B)BA=_ageB=t¯0

Transfer: Age Reset

When an APowerNft is transferred via safeTransferFrom() or safeBatchTransferFrom(), the contract executes:

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

solidity
function yearOf(uint256 nftId) public pure returns (uint256) {
    return Nft.yearOf(nftId);  // nftId / 100
}

The current year is:

solidity
function year() public view returns (uint256) {
    return Nft.year();  // (block.timestamp / 365.25 days) + 1970
}

The age in years is nowYearnftYear. This is floor-rounded (integer division), meaning an NFT minted in 2026 only gains 1 year of age when the calendar reaches 2027, regardless of the specific month.

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:

ConfigurationadddivmulexpShape
Default (linear)01100,000256APBy
Flat + bump500,00010256APB=500,000 constant
Concave025625128APBy (diminishing)
Accelerating02561512APBy2

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:

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

solidity
apbs[id].append(block.timestamp, currRate);  // no meta

Rpp Constraints

setAPB() validates parameter changes similarly to setAPR():

  • Array: Length 4, div and mul non-zero (via Rpp.checkArray())
  • Value: next[0.5×last,2.0×last] 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

  1. APB is always non-negative: eval3 outputs are unsigned; no negative bonuses
  2. APB(0) = add: A freshly staked NFT (0 years) earns only the additive constant
  3. APB is additive: totalRate=APR+APB, never multiplicative
  4. APB resets on transfer: Buyers of old positions receive no accumulated age benefit
  5. APB is level-independent: Same APB per year regardless of NFT level
  6. Only governance can change parameters: setAPB() is gated by APB_ROLE