Defensive Mechanisms
The XPower protocol layers seven independent defensive mechanisms, each addressing a specific class of attack. Together they provide defense-in-depth against economic manipulation, reentrancy, unauthorized access, and governance abuse.
ReentrancyGuardTransient (EIP-1153)
Contracts: MoeTreasury (source/contracts/MoeTreasury.sol), SovMigratable (source/contracts/base/Migratable.sol:254)
The protocol uses OpenZeppelin's ReentrancyGuardTransient instead of the standard ReentrancyGuard. EIP-1153 transient storage (the TLOAD/TSTORE opcodes) provides reentrancy protection that is automatically cleared at the end of each transaction — no SSTORE cost for resetting the lock.
contract MoeTreasury is ReentrancyGuardTransient, ... {
function claim(address account, uint256 nftId, uint256 amount, uint256 nonce)
external
nonReentrant // ← transient lock
banq(account, amount, nonce)
{ ... }
}Protected functions:
MoeTreasury.claim()— single NFT reward claim with Banq wrapperMoeTreasury.claimBatch()— batch reward claim with nonce gateSovMigratable._migrateFrom()— APOW migration with cross-contract XPOW transfer
The transient storage model means the reentrancy guard costs approximately 100 gas to set (TSTORE) and 0 gas to clear, compared to ~5,000 gas (SSTORE warm) for the traditional storage-based guard.
Limitation: The guard only prevents reentrancy from the entry point where nonReentrant is applied. Functions called internally that also modify state must be carefully sequenced or individually guarded. In MoeTreasury, the banq modifier wraps external calls (pool.call, _token.burnFrom) after the body executes, so the _claimed mapping update is sequenced before those external calls.
Banq Anti-Gaming
Contract: Banq (source/contracts/libs/Banq.sol) Used by: MoeTreasury (source/contracts/MoeTreasury.sol:25), SovMigratable (source/contracts/base/Migratable.sol:254)
The Banq contract implements four layers of supply control in a single atomic modifier:
MIN_NET2RIP Threshold (100)
uint256 public constant MIN_NET2RIP = 100;After the modified function's body executes, _banq computes the net balance increase amount, the excess is classified as "rip" supply and the ratio MIN_NET2RIP:
If the ratio falls below this threshold, the transaction reverts with InsufficientAmount. This means for every 1 token of unexpected balance increase, at most
Excess Burning
When rip_balance > 0 and the MIN_NET2RIP check passes:
_token.burnFrom(account, rip_balance);Illicit tokens are permanently destroyed atomically within the same transaction that created them. No governance vote or admin intervention is required.
Supply Cap Enforcement
if (pool == address(0)) {
if (_token.totalSupply() > _free_supply) {
revert Capped(_free_supply);
}
}Before the lending pool is initialized, total supply must not exceed the free-supply cap. For MoeTreasury, this is 43,830 APOW — the protocol's economic cap. After pool initialization, the pool assumes responsibility for supply constraints.
Pool Lock
When a pool address is configured via init():
_supply(account, min_balance, nonce);The net balance is supplied to the lending pool rather than remaining in the account. The nonce parameter acts as a proof-of-work gate — only transactions providing a valid PoW nonce can supply tokens to the pool. The lock duration is set to type(uint256).max (permanent).
This removes tokens from direct account control, preventing their use as collateral for flash loans or other leveraged attacks.
Role-Based Access Control
Contract: Supervised (source/contracts/base/Supervised.sol)
The protocol uses OpenZeppelin's AccessControl with a two-tier model: each functional role has a corresponding admin role that can grant and revoke it. All roles are assigned to the deployer at construction and transferred to designated multi-sig addresses post-deployment.
| Role | Admin Role | Scope | Contract |
|---|---|---|---|
DEFAULT_ADMIN_ROLE | (built-in) | Grant/revoke all other admin roles; initialize Banq pool | Supervised, Banq |
APR_ROLE | APR_ADMIN_ROLE | Set APR polynomial parameters | MoeTreasury |
APB_ROLE | APB_ADMIN_ROLE | Set APB polynomial parameters | MoeTreasury |
MOE_SEAL_ROLE | MOE_SEAL_ADMIN_ROLE | Seal XPOW migration | MoeMigratable |
SOV_SEAL_ROLE | SOV_SEAL_ADMIN_ROLE | Seal APOW migration | SovMigratable |
NFT_SEAL_ROLE | NFT_SEAL_ADMIN_ROLE | Seal NFT migration | NftMigratable |
NFT_OPEN_ROLE | NFT_OPEN_ADMIN_ROLE | Open NFT emigration window | NftMigratable |
NFT_ROYAL_ROLE | NFT_ROYAL_ADMIN_ROLE | Set royalty beneficiary | NftRoyalty |
URI_DATA_ROLE | URI_DATA_ADMIN_ROLE | Set contract and token URIs | URIMalleable |
Key design properties:
- Role separation: APR and APB parameters require different roles, preventing a single key from controlling both rate dimensions
- Admin separation: Each functional role's admin is independent — compromising one admin role does not compromise others
- No contract ownership dependence: Unlike
onlyOwner, roles can be granted to any address and revoked without changing contract ownership - Immutable after construction: Role structure is defined in
Supervisedconstructors and cannot be changed
Rpp Reparametrization Limits
Library: Rpp (source/contracts/libs/Rpp.sol) Used by: MoeTreasury.setAPR(), MoeTreasury.setAPB()
The Rpp (Rug Pull Protection) library enforces two constraints on APR and APB parameter changes:
Value Bounds: 0.5× to 2.0×
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));
}
}Each parameter update must not reduce the target rate by more than half (last == 0), the value is bounded by a unit ceiling — for APR this is 1e6 (100%), for APB this is also 1e6.
These bounds apply to both the computed rate target (aprTargetOf / apbTargetOf) and the mean parameter value (_aprMeanOf / _apbMeanOf), providing two layers of reparametrization protection.
Frequency Limit: 1-Month Minimum
function checkStamp(uint256 next, uint256 last) internal pure {
if (last > 0) {
require(next > Constant.MONTH + last, TooQuick(next, last));
}
}Parameter changes are limited to at most once per month (aprs[id].append(block.timestamp, currRate, "S"), and the next change must have a timestamp at least one month later.
Parameter Validation
function checkArray(uint256[] memory array) internal pure {
require(array.length == 4, InvalidArrayLength(array.length));
require(array[1] > 0, InvalidArrayIndex(1));
require(array[2] > 0, InvalidArrayIndex(2));
}The parameter array must have exactly 4 coefficients, and both the divisor (array[1]) and multiplier (array[2]) must be non-zero to prevent division-by-zero or all-zero parameter sets.
Block Hash Freshness
Contract: XPower (source/contracts/XPower.sol)
The proof-of-work system binds mining to Avalanche block hashes with a 1-hour freshness window:
function init() external {
uint256 interval = currentInterval();
if (uint256(_blockHashes[interval]) == 0) {
bytes32 blockHash = blockhash(block.number - 1);
uint256 timestamp = block.timestamp;
_blockHashes[interval] = blockHash;
_timestamps[blockHash] = timestamp;
emit Init(blockHash, timestamp);
} else { ... }
}
function _recent(bytes32 blockHash) private view returns (bool) {
return _timestamps[blockHash] >= currentInterval() * (1 hours);
}A block hash is valid for mining only if its cached timestamp is at or after the start of the current interval. Since currentInterval() computes block.timestamp / 3600, a hash cached in interval
This prevents:
- Pre-computation: An attacker cannot mine against future block hashes because they must wait for the block to exist on-chain and for
init()to cache it - Replay: A nonce valid in interval
cannot be reused in interval because the freshness check will fail - Stale submission: Solutions found late in an interval must be submitted before the interval boundary
Migration Sealing
Contract: Migratable (source/contracts/base/Migratable.sol), MoeMigratable, SovMigratable, NftMigratable (source/contracts/base/NftMigratable.sol)
The migration system implements a two-tier defense against migration window abuse:
One-Way Sealing
function seal(uint256 index) external onlyRole(MOE_SEAL_ROLE) {
_seal(index);
}
function sealAll() external onlyRole(MOE_SEAL_ROLE) {
_sealAll();
}The seal() and sealAll() functions are strictly one-way — they set _sealed[index] = true with no corresponding unseal mechanism. Once migration is sealed for a legacy contract index, it is permanently closed.
Hard Deadline
constructor(address[] memory base, uint256 deadlineIn) {
_deadlineBy = block.timestamp + deadlineIn; // immutable
}The deadline is set relative to construction time and stored as an immutable variable — it cannot be modified after deployment. The deployment scripts set deadlineIn = 126,230,400 (~4 years). After the deadline, _premigrate() reverts:
require(_deadlineBy >= block.timestamp, DeadlinePassed(_deadlineBy));NFT Emigration Window
The NftMigratable contract adds a third mechanism: an optional 1-week reverse-migration window controlled by NFT_OPEN_ROLE. This allows emergency rollback of NFT migration if issues are discovered, but only during an explicitly opened window:
function migratable(bool flag) external onlyRole(NFT_OPEN_ROLE) {
require(_deadlineBy >= block.timestamp, DeadlinePassed(_deadlineBy));
require(flag && _migratable == 0, ...);
_migratable = flag ? block.timestamp + 1 weeks : 0;
}The emigration window is one-time (_migratable == 0 guard) and automatically closes after 1 week (block.timestamp + 1 weeks).
URIMalleable Time-Lock
Contract: URIMalleable (source/contracts/base/URIMalleable.sol)
NFT metadata URIs can be updated by URI_DATA_ROLE holders, but the setURI(string, uint256) function for year-specific URIs imposes a 10-year permanence window:
uint256 private constant DECADE = 10;
function setURI(string memory newuri, uint256 year) external onlyRole(URI_DATA_ROLE) {
if (!_empty(_uris[year])) {
require(year + DECADE > Nft.year(), ImmalleableYear(year));
}
require(year > 2020, InvalidYear(year));
_uris[year] = newuri;
}Once an NFT's mint year is more than 10 years in the past (year + DECADE ≤ Nft.year()), the URI for that year becomes immalleable — any attempt to change it reverts with ImmalleableYear. This ensures that NFT metadata for older tokens cannot be retroactively altered.
The default URI (setURI(string) without year) and the contract-level URI (setContractURI(string)) remain mutable indefinitely — the time-lock applies only to year-specific per-token URIs.
Duplicate Prevention (3D Unique Constraint)
Contract: XPower (source/contracts/XPower.sol)
Each mining solution is protected against replay by a three-dimensional uniqueness constraint:
bytes32 nonceHash = keccak256(bytes.concat(
bytes20(uint160(address(this)) ^ uint160(to)),
blockHash,
data
));
bytes32 pairIndex = nonceHash ^ blockHash;
require(_unique(pairIndex), DuplicatePairIndex(pairIndex));
_hashes[pairIndex] = true;The three dimensions:
- Beneficiary (
to) — XORed with contract address to bind the solution to both recipient and deployment - Block hash (
blockHash) — anchors the solution to a specific Avalanche block - Nonce data (
data) — the miner's 32-byte search result
The pairIndex = nonceHash ^ blockHash XOR operation condenses these three dimensions into a single bytes32 key stored in the _hashes mapping. Once set to true, any future attempt to mint with the same (to, blockHash, data) tuple reverts with DuplicatePairIndex.
This prevents:
- Replay across intervals: Even if the same nonce works for a different block hash, the XOR produces a different
pairIndex - Recipient spoofing: Changing the beneficiary changes the XOR prefix, producing a different
nonceHashand thus a differentpairIndex - Cross-contract reuse: The contract address is baked into the XOR prefix — nonces from one deployment are invalid for another