Skip to content

Threat Model

The XPower protocol manages on-chain assets across 6 core contracts deployed on Avalanche C-Chain, processing mint transactions, staking operations, reward claims, and token migrations. This document enumerates the assets at risk, the actors who interact with the system, the threats each actor can pose, and the mitigations built into the protocol.

Assets

AssetLocationTypeDescription
XPOW supplyXPower (moe)ERC20, unboundedCreated exclusively via proof-of-work Keccak-256 nonce discovery. Each mined solution mints 2^z − 1 XPOW to both beneficiary and MoeTreasury.
APOW supplyAPower (sov)ERC20, cappedStore-of-value token minted as staking rewards. Supply cap: 43,830. Rate-limited to ~1 token/min mean. Backed by treasury XPOW.
Treasury XPOW balanceMoeTreasury (mty)ERC20 balanceAccumulated from 50% mining split. Collateral for APOW minting. Used during APOW unwrap via APower.burn().
XPowerNft tokensXPowerNft (nft)ERC1155Deposit receipts minted by burning XPOW. 34 levels (0, 3, 6, …, 99), each with a denomination in XPOW. Redeemable after maturity 2^(level/3) − 1 years.
Staked APowerNftsAPowerNft (ppt), NftTreasury (nty)ERC1155Staking receipts representing locked XPowerNfts. Track cumulative holder age via time-weighted accounting. Delegate reward calculation to MoeTreasury.
Unclaimed APOW rewardsMoeTreasury (mty)BookkeepingAccrued but unclaimed rewards: (APR + APB) × age × denomination / (1e6 × CENTURY). Tracked per (account, nftId) in _claimed mapping.
APR parametersMoeTreasury._apruint256[4]Polynomial coefficients per NFT level for annual percentage rate. Default: [0, 3, 3375000, 256]. Governed by APR_ROLE.
APB parametersMoeTreasury._apbuint256[4]Polynomial coefficients for annual percentage bonus. Default: [0, 1, 100000, 256]. Governed by APB_ROLE.
Rate integratorsMoeTreasury.aprs, apbsIntegrator.Item[]Time-weighted history [(stamp, value), …] per tier. Duration-weighted mean dampens parameter changes.
Migration sealsMigratable._sealedbool[]Per-index seal flags. One-way seal() / sealAll(). Controlled by MOE_SEAL_ROLE, SOV_SEAL_ROLE, NFT_SEAL_ROLE.
Migration deadlineMigratable._deadlineByuint256 (immutable)Absolute timestamp (construction + ~4 years). Past this, _premigrate() reverts. Cannot be extended.
NFT metadata URIsURIMalleable._urismapping(year → URI)Per-year metadata URIs. Immalleable after 10 years. Governed by URI_DATA_ROLE.
Block hash cacheXPower._blockHashes, _timestampsmapping(uint256 → bytes32), mapping(bytes32 → uint256)1-hour interval → block hash, block hash → timestamp. Used for PoW freshness validation.
Nonce registryXPower._hashesmapping(bytes32 → bool)Permanent record of used pairIndex values. Prevents nonce replay indefinitely.

Asset Dependency Chain

Mining (Keccak-256 nonce discovery)


XPOW (50% to beneficiary, 50% to treasury)

    ├─── XPowerNft (deposit minting: burn XPOW → receive NFT)
    │         │
    │         ▼
    │    APowerNft (staking: lock XPowerNft → receive staking receipt)
    │         │
    │         ▼
    │    APOW Rewards (claim: accumulated (APR+APB) × age × denomination)
    │         │
    │         ▼
    │    APOW (minted by MoeTreasury, backed by treasury XPOW)

    └─── Treasury XPOW (accumulates from mining, backs APOW unwrap)

A failure at any layer propagates downward: compromised XPOW devalues APOW; compromised staking halts reward accrual; compromised treasury breaks APOW backing. The deepest layer (mining) is the most resistant — Keccak-256 preimage resistance is a cryptographic foundation shared by Ethereum itself.

Actors

ActorOn-Chain OperationsTrust LevelAttack Surface
MinersXPower.init(), XPower.mint()UntrustedNonce replay, pre-computation, hash selection, difficulty gaming
StakersNftTreasury.stake(), unstake(), MoeTreasury.claim(), claimBatch()UntrustedDouble-claiming, flash-staking, reward manipulation, griefing
MigrantsmigrateFrom() on XPower, APower, XPowerNftUntrustedLate migration, decimal mismatch, cross-contract manipulation
DEFAULT_ADMIN_ROLEBanq.init(), role grant/revokeFully trustedPool misconfiguration, role assignment to malicious addresses
APR_ROLE / APB_ROLEsetAPR(), setAPB(), setAPRBatch(), setAPBBatch()Trusted with boundsRate manipulation within Rpp limits, coordinated multi-role changes
MOE_SEAL_ROLE / SOV_SEAL_ROLE / NFT_SEAL_ROLEseal(), sealAll()Trusted (one-way only)Premature sealing, selective sealing
NFT_OPEN_ROLEmigratable(bool)Trusted (one-time, 1-week)Opening emigration at unfavorable times
NFT_ROYAL_ROLEsetRoyal(address)TrustedRoyalty beneficiary manipulation
URI_DATA_ROLEsetURI(), setContractURI()Trusted (10-year lock)Metadata tampering within malleability window
Lending PoolCalled via Banq._supply()pool.call(...)UntrustedFailing to enforce supply constraints, reentrancy, reverting without reason
Legacy ContractsburnFrom() / burn() via migrationTrusted (burn-only)Unexpected behavior if compromised before migration
Avalanche ValidatorsProduce blocks, order transactionsTrusted (L1 consensus)Censoring init(), selectively including mint(), timestamp manipulation

Threat Table

ThreatActorImpactLikelihoodMitigation
Pre-computed noncesMinersCriticalLow1-hr freshness; init() binds to blockhash(block.number − 1); _recent() expires at interval boundary
Nonce replayMinersHighVery Low3D unique pairIndex = nonceHash ^ blockHash; _hashes mapping permanent
Block hash selection gamingMinersLowMediumAny cached hash in current interval is valid; no advantage to choosing one over another
Double-claiming rewardsStakersHighVery Low_claimed tracks cumulative; claimable() = rewardOf − claimed; nonReentrant guard
Flash-stake manipulationStakersMediumLowTime-weighted _age += amount × block.timestamp; zero rewards for sub-second holding
Treasury drain via APOW unwrapStakersCriticalVery LowRate limiter (~1/min mean); 43,830 cap; Banq pool lock
APR/APB abuse within boundsRate SettersMediumMediumRpp 0.5×–2.0×; 1-month cooldown; integrator dampens via weighted mean
Coordinated APR+APB changeColluding Rate SettersMediumLowSeparate roles + admin; separate multi-sig signers
Reentrancy via ERC1155 callbackExternal ContractsCriticalLowReentrancyGuardTransient (EIP-1153); atomic Banq modifier
Reentrancy via pool callbackLending PoolHighLowBanq._supply() called after state updates; nonReentrant on entry points
Supply cap bypassStakers + PoolCriticalVery LowHard cap in Banq ctor; pool assumes responsibility post-init(); pool independently audited
NFT metadata tamperingURI ManagerMediumMedium10-year immalleability on year-specific URIs
Migration after deadlineMigrantsHighVery Low_deadlineBy immutable; reverts past deadline; one-way seals
Premature migration sealSeal AuthorityMediumMediumOne-way seal requires role auth; seal events emit for transparency
Legacy contract compromiseExternal ActorCriticalLowBurn-only interaction during migration; 4-year deadline limits exposure
Block hash censorshipValidatorsMediumLowAny miner can init(); censorship affects all miners equally
Gas griefing on batch opsAnyLowVery LowBounded max iterations (102 NFTs); 15M gas ceiling sufficient
Timestamp manipulation (±30s)ValidatorsLowVery LowProtocol uses hour+ granularity; 30s variance is negligible
Flash-loan backed supply attackStakersHighLowBanq pool lock removes APOW from accounts; flash-loaned APOW cannot be supplied

Attack Vector Deep Dives

Pre-Computed Nonce Attack

An attacker operates off-chain mining software pre-computing nonces against a library of historical block hashes. When XPower.init() caches a hash matching their pre-computed set, they submit the matching nonce instantly.

Why it fails: The _recent() check validates _timestamps[blockHash] >= currentInterval() × 1 hours. A hash cached in interval N is invalid in interval N+1 because its timestamp t is strictly less than the start of N+1. The attacker's pre-computed nonces target hashes from prior intervals, all of which fail _recent().

Even for the current interval, the attacker must predict which block hash init() will cache before it happens. Since init() uses blockhash(block.number − 1) of the block that includes the init() transaction, prediction requires knowing the parent hash of the init()-containing block before it's produced — equivalent to predicting Avalanche block production.

Double-Claim Attack (Detailed)

A staker with a 1-year-old level-6 NFT (denomination 64 XPOW, APR ~3.375%) has accrued approximately 0.03375 × 365 × 86,400 × 64 / (1e6 × 31556952) ≈ 2.14 APOW. The staker calls claim():

  1. claimable() computes rewardOf() − claimed = 2.14 − 0 = 2.14 APOW
  2. _claimed[account][nftId] += 2.14 (now 2.14)
  3. _sov.mint(account, 2.14) → 2.14 APOW sent to staker
  4. _minted[account][nftId] += 2.14

If the attacker could re-enter claim() between steps 2 and 3 (before _claimed is updated), claimable() would still return 2.14. But step 2 executes before the external call in step 3, so any re-entrant call to claimable() sees _claimed = 2.14 and rewardOf() = 2.14, yielding 0 claimable. The nonReentrant modifier adds a second barrier.

Block Hash Selection (Audit Finding H-01)

The XPower.mint() function accepts a caller-supplied blockHash without verifying it matches _blockHashes[currentInterval()]. This means a miner could use any block hash that was cached (via init()) within the current interval, not necessarily the one they personally init()-ed.

Why it's safe: The _recent() function validates the hash was cached at _timestamps[blockHash] >= currentInterval() × 1 hours. Only hashes cached in the current interval pass. Since init() is idempotent within an interval (first caller's hash wins, subsequent callers get the same hash), all miners in an interval share the same block hash. The "selection" concern only matters in a theoretical edge case where two different init() transactions land in the same interval using different parent blocks — but Avalanche block times (~2s) and the 1-hour interval make this scenario harmless: both hashes are valid Avalanche hashes from moments apart, and Keccak-256 treats them identically for difficulty purposes.

Flash-Stake Rate Manipulation

A whale holding XPOW attempts: deposit → mint NFT → stake → claim → unstake → redeem, all atomically.

Why it fails at the age layer:

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

The age contribution is amount×timestamp. For a flash-stake of Δt seconds (same block, same timestamp), the mint and burn use the same block.timestamp:

  • Mint: _age += amount × T
  • Burn: _age -= amount × T
  • Net age contribution: 0

The reward formula multiplies by age, so zero age ⇒ zero reward. The rate integrator also plays a role: parameter changes take effect gradually via duration-weighted mean, so even if block.timestamp differed by seconds across blocks, the integrator's mean over a sub-minute period is essentially the target rate — no spike to exploit.

Treasury Drain via Rate Abuse (Long Game)

A malicious rate setter attempts to drain the treasury by raising both APR and APB to their maximum every month, compounding 12 times annually.

Why it fails at the parameter layer: Rpp bounds (0.5×–2.0×) and the 1-month cooldown create a maximum compound rate of 212=4,096× per year. But the polynomial evaluation divides by Power.raise(1e6, array[3]) where array[3] defaults to 256:

rate=poly(level)×106106×256

The denominator (101536) dwarfs any numerator achievable within the 2× monthly bound. Even at the mathematical limit, the APR cannot exceed a small fraction of 1e6 (100% per century). The rate setter can influence rewards within a bounded band but cannot create an economically significant deviation.

Why it fails at the treasury layer: The APOW supply cap (43,830) limits total drain even if all APOW is unwrapped. The rate limiter (~1/min mean) means even at maximum rates, APOW minting is throttled, giving stakers and the community months to notice and respond (e.g., by revoking the compromised role via the admin role).

Lending Pool Reentrancy

The Banq._supply() function uses pool.call(args1) — a low-level call to an external address with arbitrary code. A malicious pool could re-enter MoeTreasury during this call.

Why it fails: The call stack at _supply() is:

claim() [nonReentrant guard set]
  → banq modifier
    → [function body executes]
    → _banq()
      → balanceOf() check
      → burnFrom() if excess
      → _supply() [pool.call happens here]

The nonReentrant modifier was set in claim() and never cleared during this execution. Any attempt by the pool to re-enter claim() (or any other nonReentrant-protected function) reverts. The burn of excess tokens happens before the pool call, so even if the pool reverts, excess supply is already destroyed.

Trust Assumptions

  1. Avalanche consensus finality: Validators produce blocks with honest, non-predictable hashes. Censorship of init() requires dropping all init() transactions for an interval, which also prevents mining in that interval for all participants equally. No selective advantage is possible.

  2. Admin multi-sig integrity: Role addresses use a multi-signature wallet with threshold (e.g., 2-of-3). A single compromised signer cannot act alone. Rpp bounds, deadline immutability, and one-way seals provide structural limits even if all signers collude.

  3. Role separation: Independent roles (APR vs APB, MOE seal vs SOV seal vs NFT seal) assume holders operate independently. This is enforced by separate admin roles and separate multi-sig deployments.

  4. Keccak-256 preimage resistance: The proof-of-work system assumes Keccak-256 is computationally irreversible. A break would invalidate PoW entirely but would also break Ethereum's state trie — this is a shared foundation-level assumption.

  5. blockhash availability: blockhash(block.number − 1) returns a non-zero value for the parent block. The EVM returns zero for blocks more than 256 blocks in the past, but the 1-hour interval (≈1,800 blocks) ensures the parent block is always within this window at init time.

  6. Pool contract correctness: After Banq.init(pool), supply cap enforcement delegates to the pool. The pool must independently enforce equivalent constraints. No tokens are supplied to the pool before init() is called, and init() is a one-time operation.

  7. Avalanche C-Chain liveness: Chain halts pause mining (no init() calls) but staking rewards continue accruing based on elapsed wall-clock time once the chain resumes. Migration deadlines are timestamp-based, not block-number-based, so chain halts do not extend the window.

Cross-Contract Attack Analysis

The protocol's safety depends on coordinated behavior across 6 contracts. Some attacks span contract boundaries.

XPOW Supply Inflation → APOW Devaluation

If an attacker finds a way to mint XPOW without mining (bypassing the mint() proof-of-work verification), the treasury would receive half of the illegitimate supply. This would:

  • Inflate XPOW supply, reducing its market value
  • Inflate the treasury balance, allowing more APOW to be minted
  • Ultimately devalue APOW through increased backing supply

Mitigation: XPower.mint() has multiple independent guards: _recent() freshness, _unique() pair index check, _zerosOf() difficulty validation, and the Banq-like _hashes registry. Bypassing any one guard is insufficient. The owner() of XPower is MoeTreasury, so even the contract owner cannot call _mint() directly — the _mint function is internal and only accessible through the mint() entry point.

Staked NFT Theft via ERC1155 Approval

An attacker tricks a user into calling setApprovalForAll(attacker, true) on APowerNft, then calls safeTransferFrom() to steal staked NFTs. Because APowerNft stores age data per (account, nftId), the transfer triggers _pushBurn() which recalculates age for the sender via _mty.refreshClaimed().

Mitigation: This is standard ERC1155 allowance risk, not a protocol vulnerability. Users must exercise caution with setApprovalForAll(). The protocol cannot prevent authorized transfers, but the age recalculation ensures reward accounting remains correct regardless of who holds the NFT.

Cross-Migration Mismatch (XPOW ↔ APOW)

The SovMigratable._migrateFrom() function migrates SOV (APOW) tokens, which internally migrates MOE (XPOW) tokens to maintain the backing ratio:

solidity
uint256 new_moe = moeUnits(new_sov);
uint256 old_moe = _moeMigratable.oldUnits(new_moe, idx_moe[0]);
uint256 mig_moe = _moeMigratable.migrateFrom(account, old_moe, idx_moe);
assert(_moeMigratable.transferFrom(account, address(this), mig_moe));

Mitigation: The assert() calls in this function guarantee that:

  • mig_sov == new_sov — migrated SOV amount matches expected
  • _moeMigratable.balanceOf() check via transferFrom — MOE tokens actually transferred

If either assertion fails, the entire transaction reverts. The use of assert() rather than require() is intentional — assertion failures indicate invariant violations that should never occur in correct contract state, and they consume all remaining gas.

Economic Attack Surface

Beyond code-level exploits, the protocol has economic vectors that rely on market conditions rather than code bugs.

Profitability Manipulation via Gas Price

On Avalanche C-Chain, mint() gas cost is ~80,000 gas (with pool locking) to ~10,000 gas (without). At 25 nAVAX/gas, a z = 1 solution costs ~0.00025 AVAX to claim for 0.5 XPOW (beneficiary half). If XPOW's market value drops below the gas cost, mining becomes uneconomical, reducing hash rate and making higher-difficulty solutions rarer — which increases per-solution reward and re-balances incentives.

Mitigation: The difficulty/reward curve is self-regulating. As miners drop out, higher difficulties dominate, increasing reward per successful solution. The equilibrium is market-driven — the protocol provides the formula, the market provides the value.

Staking Concentration Attack

If a single entity holds the majority of staked NFTs at a given level, refreshRates() will compute an APR scalar that concentrates rewards in that entity's favor:

solidity
_apr[id][0] = _scalar(APR_MUL, sum, bins, shares[i]);
// = APR_MUL * sum / (bins * shares[i])

When bins = 1 (only one level has stakers), the scalar is APR_MUL * sum / (1 * shares[i]) = APR_MUL, which is the default. The concentration increases when more bins are active, distributing the scalar across levels proportionally to their share. An entity with majority stake in one level benefits if other levels are also staked but have smaller relative shares.

Mitigation: This is an intended feature of the dynamic rate system — rates adjust to staking distribution. A concentration attack requires capital commitment (staking XPOW), and the Rpp bounds on setAPR() prevent the rate from being manually tuned to exploit concentration.