Skip to content

Integrator Mechanism

The Integrator library (Integrator.sol:1-115) provides duration-weighted arithmetic mean computation over a time series of rate values. It is the defense against flash-staking — the strategy of entering a stake position during a high-rate window, then withdrawing while the elevated rate persists on-chain.

The Flash-Staking Problem

In a naive reward system where the current rate applies to all active stakers, an attacker can:

  1. Monitor the mempool for an impending rate increase (e.g., setAPR() with higher parameters)
  2. Stake a large position immediately after the rate change
  3. Earn rewards at the new, higher rate
  4. Unstake once the rate normalizes

If the rate change is instantaneous, the attacker captures disproportionate rewards relative to their stake duration. The integrator solves this by ensuring that the effective rate is the time-weighted average over the entire staking period, not the instantaneous rate at claim time.

Data Structure

The integrator maintains a linked list of Item structs:

solidity
struct Item {
    uint256 stamp;   // timestamp of this entry
    uint256 value;   // rate value at this timestamp
    uint256 area;    // cumulative area under the curve up to this entry
    uint256 prev;    // index of previous meta-entry (0 for normal entries)
}

The prev field implements a secondary index for meta entries (governance change records), allowing the same storage array to track both rate history and parameter-change history.

Storage Mapping

In MoeTreasury, each rate type has its own integrator:

solidity
mapping(uint256 => Integrator.Item[]) public aprs;  // APR integrators, keyed by level-id
mapping(uint256 => Integrator.Item[]) public apbs;  // APB integrator, keyed by fixed id

The APR has per-level integrators (34 possible levels), while the APB has a single shared integrator.

Appending Entries

New entries are appended via append():

solidity
function append(Item[] storage items, uint256 stamp, uint256 value, bytes1 meta) internal {
    items.push(_nextOf(items, stamp, value, meta));
}

The _nextOf function constructs the new item:

solidity
function _nextOf(Item[] storage items, uint256 stamp, uint256 value, bytes1 meta)
    private view returns (Item memory)
{
    if (items.length > 0) {
        Item memory last = items[items.length - 1];
        require(stamp >= last.stamp, InvalidTimestamp(stamp));
        uint256 area = value * (stamp - last.stamp);
        if (meta != "") last.prev = items.length;
        return Item({
            stamp: stamp,
            value: value,
            area: last.area + area,
            prev: last.prev
        });
    }
    return Item({stamp: stamp, value: value, area: 0, prev: 0});
}

For the first entry (empty list), area = 0 and prev = 0. For subsequent entries, the area is accumulated:

areai=areai1+valuei1×(stampistampi1)

Each entry i stores the cumulative area up to stamp i, using the previous value over the interval. This means the final entry's area covers the full time range from the head to the current stamp.

Rate Calculation

The effective rate at any time t with a hypothetical next value v is:

solidity
function meanOf(Item[] storage items, uint256 stamp, uint256 value) internal view returns (uint256) {
    uint256 area = areaOf(items, stamp, value);
    Item memory head = headOf(items);
    if (stamp > head.stamp) {
        return area / (stamp - head.stamp);
    }
    return head.value;
}

Where areaOf extends the cumulative area to the query timestamp:

solidity
function areaOf(Item[] storage items, uint256 stamp, uint256 value) internal view returns (uint256) {
    if (items.length > 0) {
        Item memory last = items[items.length - 1];
        require(stamp >= last.stamp, InvalidTimestamp(stamp));
        uint256 area = value * (stamp - last.stamp);
        return last.area + area;
    }
    return 0;
}

Mathematical Derivation

Given a history of n entries with stamps t0,t1,,tn1 and rates r0,r1,,rn1, and a query at time t with rate r, the effective rate is:

r¯(t)=i=0n2ri×(ti+1ti)+r×(ttn1)tt0

Equivalently, the area under the piecewise-constant rate curve divided by the total elapsed time:

r¯(t)=A(t)tt0

Where A(t)=arean1+r×(ttn1).

Visualization

Rate
 ^
 |     r0 ─────────┐
 |                 │
 |                 ├─ r1 ──────┐
 |                 │           │
 |                 │           ├─ r2 ────┬─ r (query)
 |                 │           │         │
 +─────┬───────────┬───────────┬─────────┬───────> Time
      t0           t1          t2        t

The shaded area divided by (tt0) is the effective rate. A brief spike at r2 contributes little to the area because the duration (tt2) is small.

When the Integrator Resets

The integrator state is never deleted — entries are only appended. A "reset" occurs implicitly when:

1. Initialization (First Entry)

When shares first appear at a level (refreshRates() detects item.stamp == 0), a laggy initialization creates two entries:

solidity
aprs[id].append(block.timestamp - Constant.MONTH, target);
aprs[id].append(block.timestamp, target);

This backdates the first entry by one month, immediately establishing a one-month averaging window. Without this, the freshly initialized integrator would have zero averaging history, potentially allowing a spike to dominate.

2. Rate Changes

When setAPR() or setAPB() changes parameters:

  1. The old rate is appended at block.timestamp (locking in the area up to now)
  2. The new parameters are stored
  3. Future meanOf() queries will use the new target rate as the hypothetical value, computing the area under the old actual rates

This creates a new "integration window" — the effective rate gradually transitions from the old rate to the new target as more time accumulates under the new rate.

3. Rate Convergence

When refreshRates() detects that the last integrator value matches the new target:

solidity
if (item.value != target) {
    aprs[id].append(block.timestamp, target);
}

If they match, no new entry is appended — the rate is already in effect and integration continues seamlessly.

Gas Implications

Each integrator Item consumes one SSTORE (20k gas for a non-zero slot, plus the struct's multiple slots). The struct layout is:

FieldTypeSlots
stampuint2561
valueuint2561
areauint2561
prevuint2561

Each appended entry costs approximately 40k–85k gas (one SSTORE per slot, depending on zero-to-non-zero transitions). The meanOf() and areaOf() functions are read-only (STATICCALL, 100–300 gas plus SLOADs for the last entry).

Storage Growth

The integrator array grows monotonically. With 34 levels, each rate change (affecting all levels or a batch) appends 34 entries × 4 slots = 136 storage slots. Over years of operation with monthly rate adjustments, this could reach:

  • 12 changes/year × 10 years × 34 levels = 4,080 entries
  • 4,080 entries × 4 slots × 32 bytes = ~522 KB of storage (theoretical maximum)

In practice, rate changes are infrequent, and only actively-staked levels grow. The prev meta field creates additional entries for governance records (one extra "S" entry per setAPR()), approximately doubling the count for APR but not for APB.

Interaction with Dynamic Scalar

The dynamic scalar computed by refreshRates() feeds directly into the APR integrator:

  1. refreshRates() computes the new scalar addend for each level
  2. If the scalar changed (value differs), aprs[id].append(block.timestamp, target) records the transition
  3. The integrator now averages between the old scalar-based rate and the new one

Because refreshRates() is called on every stake and unstake, the dynamic scalar adjusts continuously. However, the integrator prevents these adjustments from being exploited — a staker entering after a scalar increase must wait for the integrator to converge before realizing the full new rate.

Why Area-Weighted Averaging Prevents Exploitation

Consider an attacker who observes that level 30's rate will increase from r0 to r1>r0 at time t1. Without the integrator, staking at t1 and claiming at t1+ε yields rewards at the full r1 rate. With the integrator:

Without integrator (vulnerable):

Rattackr1×ε×D

(The attacker earns momentarily at the new rate.)

With integrator (protected):

r¯=r0×(t1t0)+r1×εt1t0+ε

As t1t0 grows large (i.e., the old rate was in effect for a long time), r¯r0 for small ε. The attacker must wait for the averaging window to shift:

r¯r1only whentt1tt01

This requires the attacker to keep their position staked for a duration comparable to the pre-change window, eliminating the flash-staking advantage.

Concrete Example

Suppose level 30 has been at rate r0=30,000,000 PPM for 30 days, and governance increases it to r1=60,000,000 PPM. An attacker stakes immediately after:

Elapsed time after changeIntegrator rateEffective vs. target
0 seconds30,000,00050%
1 day30,967,74251.6%
7 days36,000,00060%
30 days45,000,00075%
90 days52,500,00087.5%

The attacker must wait months before the effective rate approaches the new target — by which point the rate difference may no longer exist. This eliminates the economic incentive for flash-staking.

Meta Entries and Governance Tracking

The "S" meta key used in setAPR() creates a secondary linked list of governance entries. When meta != "":

solidity
if (meta != "") last.prev = items.length;

The lastOf(items, "S") function follows these prev links to retrieve the most recent governance entry:

solidity
function lastOf(Item[] storage items, bytes1 meta) internal view returns (Item memory) {
    if (items.length > 0) {
        Item memory last = items[items.length - 1];
        if (meta != "") return items[last.prev];
        return last;
    }
    return Item({stamp: 0, value: 0, area: 0, prev: 0});
}

This allows Rpp.checkStamp() to verify the 1-month minimum between governance changes without scanning the entire array — the prev pointer jumps directly to the last governance entry.

Invariants

  1. Monotonic stamps: Every new entry must have stamp >= last.stamp — time cannot go backward
  2. Non-negative area: All values are unsigned; area monotonically increases
  3. Mean convergence: As t under a constant rate r, r¯r
  4. Empty integrator: headOf() and lastOf() return zero-initialized items for empty arrays
  5. Immutable history: Entries are never deleted or modified — only appended