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:
- Monitor the mempool for an impending rate increase (e.g.,
setAPR()with higher parameters) - Stake a large position immediately after the rate change
- Earn rewards at the new, higher rate
- 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:
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:
mapping(uint256 => Integrator.Item[]) public aprs; // APR integrators, keyed by level-id
mapping(uint256 => Integrator.Item[]) public apbs; // APB integrator, keyed by fixed idThe APR has per-level integrators (34 possible levels), while the APB has a single shared integrator.
Appending Entries
New entries are appended via append():
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:
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:
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
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:
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
Equivalently, the area under the piecewise-constant rate curve divided by the total elapsed time:
Where
Visualization
Rate
^
| r0 ─────────┐
| │
| ├─ r1 ──────┐
| │ │
| │ ├─ r2 ────┬─ r (query)
| │ │ │
+─────┬───────────┬───────────┬─────────┬───────> Time
t0 t1 t2 tThe shaded area divided by
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:
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:
- The old rate is appended at
block.timestamp(locking in the area up to now) - The new parameters are stored
- 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:
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:
| Field | Type | Slots |
|---|---|---|
stamp | uint256 | 1 |
value | uint256 | 1 |
area | uint256 | 1 |
prev | uint256 | 1 |
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:
refreshRates()computes the new scalar addend for each level- If the scalar changed (value differs),
aprs[id].append(block.timestamp, target)records the transition - 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
Without integrator (vulnerable):
(The attacker earns momentarily at the new rate.)
With integrator (protected):
As
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
| Elapsed time after change | Integrator rate | Effective vs. target |
|---|---|---|
| 0 seconds | 50% | |
| 1 day | 51.6% | |
| 7 days | 60% | |
| 30 days | 75% | |
| 90 days | 87.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 != "":
if (meta != "") last.prev = items.length;The lastOf(items, "S") function follows these prev links to retrieve the most recent governance entry:
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
- Monotonic stamps: Every new entry must have
stamp >= last.stamp— time cannot go backward - Non-negative area: All values are unsigned; area monotonically increases
- Mean convergence: As
under a constant rate , - Empty integrator:
headOf()andlastOf()return zero-initialized items for empty arrays - Immutable history: Entries are never deleted or modified — only appended