Skip to content

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.

solidity
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 wrapper
  • MoeTreasury.claimBatch() — batch reward claim with nonce gate
  • SovMigratable._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)

solidity
uint256 public constant MIN_NET2RIP = 100;

After the modified function's body executes, _banq computes the net balance increase Δ=balancenewbalanceold. If Δ exceeds the legitimate amount, the excess is classified as "rip" supply and the ratio Δ/rip_balance must exceed MIN_NET2RIP:

Δrip_balance>100

If the ratio falls below this threshold, the transaction reverts with InsufficientAmount. This means for every 1 token of unexpected balance increase, at most Δ/100 tokens are retained — the remainder is classified as rip and must be burned or the transaction fails.

Excess Burning

When rip_balance > 0 and the MIN_NET2RIP check passes:

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

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

solidity
_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.

RoleAdmin RoleScopeContract
DEFAULT_ADMIN_ROLE(built-in)Grant/revoke all other admin roles; initialize Banq poolSupervised, Banq
APR_ROLEAPR_ADMIN_ROLESet APR polynomial parametersMoeTreasury
APB_ROLEAPB_ADMIN_ROLESet APB polynomial parametersMoeTreasury
MOE_SEAL_ROLEMOE_SEAL_ADMIN_ROLESeal XPOW migrationMoeMigratable
SOV_SEAL_ROLESOV_SEAL_ADMIN_ROLESeal APOW migrationSovMigratable
NFT_SEAL_ROLENFT_SEAL_ADMIN_ROLESeal NFT migrationNftMigratable
NFT_OPEN_ROLENFT_OPEN_ADMIN_ROLEOpen NFT emigration windowNftMigratable
NFT_ROYAL_ROLENFT_ROYAL_ADMIN_ROLESet royalty beneficiaryNftRoyalty
URI_DATA_ROLEURI_DATA_ADMIN_ROLESet contract and token URIsURIMalleable

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 Supervised constructors 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×

solidity
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 (next0.5×last) nor increase it by more than double (next2×last). For initial settings (where 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

solidity
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 (Constant.MONTH=365.25×86400/122,629,746 seconds). The stamp of the previous change is recorded in the integrator via aprs[id].append(block.timestamp, currRate, "S"), and the next change must have a timestamp at least one month later.

Parameter Validation

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

solidity
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 N becomes invalid at the start of interval N+1.

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 N cannot be reused in interval N+1 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

solidity
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

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

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

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

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

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

  1. Beneficiary (to) — XORed with contract address to bind the solution to both recipient and deployment
  2. Block hash (blockHash) — anchors the solution to a specific Avalanche block
  3. 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 nonceHash and thus a different pairIndex
  • Cross-contract reuse: The contract address is baked into the XOR prefix — nonces from one deployment are invalid for another