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
| Asset | Location | Type | Description |
|---|---|---|---|
| XPOW supply | XPower (moe) | ERC20, unbounded | Created exclusively via proof-of-work Keccak-256 nonce discovery. Each mined solution mints 2^z − 1 XPOW to both beneficiary and MoeTreasury. |
| APOW supply | APower (sov) | ERC20, capped | Store-of-value token minted as staking rewards. Supply cap: 43,830. Rate-limited to ~1 token/min mean. Backed by treasury XPOW. |
| Treasury XPOW balance | MoeTreasury (mty) | ERC20 balance | Accumulated from 50% mining split. Collateral for APOW minting. Used during APOW unwrap via APower.burn(). |
| XPowerNft tokens | XPowerNft (nft) | ERC1155 | Deposit 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 APowerNfts | APowerNft (ppt), NftTreasury (nty) | ERC1155 | Staking receipts representing locked XPowerNfts. Track cumulative holder age via time-weighted accounting. Delegate reward calculation to MoeTreasury. |
| Unclaimed APOW rewards | MoeTreasury (mty) | Bookkeeping | Accrued but unclaimed rewards: (APR + APB) × age × denomination / (1e6 × CENTURY). Tracked per (account, nftId) in _claimed mapping. |
| APR parameters | MoeTreasury._apr | uint256[4] | Polynomial coefficients per NFT level for annual percentage rate. Default: [0, 3, 3375000, 256]. Governed by APR_ROLE. |
| APB parameters | MoeTreasury._apb | uint256[4] | Polynomial coefficients for annual percentage bonus. Default: [0, 1, 100000, 256]. Governed by APB_ROLE. |
| Rate integrators | MoeTreasury.aprs, apbs | Integrator.Item[] | Time-weighted history [(stamp, value), …] per tier. Duration-weighted mean dampens parameter changes. |
| Migration seals | Migratable._sealed | bool[] | Per-index seal flags. One-way seal() / sealAll(). Controlled by MOE_SEAL_ROLE, SOV_SEAL_ROLE, NFT_SEAL_ROLE. |
| Migration deadline | Migratable._deadlineBy | uint256 (immutable) | Absolute timestamp (construction + ~4 years). Past this, _premigrate() reverts. Cannot be extended. |
| NFT metadata URIs | URIMalleable._uris | mapping(year → URI) | Per-year metadata URIs. Immalleable after 10 years. Governed by URI_DATA_ROLE. |
| Block hash cache | XPower._blockHashes, _timestamps | mapping(uint256 → bytes32), mapping(bytes32 → uint256) | 1-hour interval → block hash, block hash → timestamp. Used for PoW freshness validation. |
| Nonce registry | XPower._hashes | mapping(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
| Actor | On-Chain Operations | Trust Level | Attack Surface |
|---|---|---|---|
| Miners | XPower.init(), XPower.mint() | Untrusted | Nonce replay, pre-computation, hash selection, difficulty gaming |
| Stakers | NftTreasury.stake(), unstake(), MoeTreasury.claim(), claimBatch() | Untrusted | Double-claiming, flash-staking, reward manipulation, griefing |
| Migrants | migrateFrom() on XPower, APower, XPowerNft | Untrusted | Late migration, decimal mismatch, cross-contract manipulation |
| DEFAULT_ADMIN_ROLE | Banq.init(), role grant/revoke | Fully trusted | Pool misconfiguration, role assignment to malicious addresses |
| APR_ROLE / APB_ROLE | setAPR(), setAPB(), setAPRBatch(), setAPBBatch() | Trusted with bounds | Rate manipulation within Rpp limits, coordinated multi-role changes |
| MOE_SEAL_ROLE / SOV_SEAL_ROLE / NFT_SEAL_ROLE | seal(), sealAll() | Trusted (one-way only) | Premature sealing, selective sealing |
| NFT_OPEN_ROLE | migratable(bool) | Trusted (one-time, 1-week) | Opening emigration at unfavorable times |
| NFT_ROYAL_ROLE | setRoyal(address) | Trusted | Royalty beneficiary manipulation |
| URI_DATA_ROLE | setURI(), setContractURI() | Trusted (10-year lock) | Metadata tampering within malleability window |
| Lending Pool | Called via Banq._supply() — pool.call(...) | Untrusted | Failing to enforce supply constraints, reentrancy, reverting without reason |
| Legacy Contracts | burnFrom() / burn() via migration | Trusted (burn-only) | Unexpected behavior if compromised before migration |
| Avalanche Validators | Produce blocks, order transactions | Trusted (L1 consensus) | Censoring init(), selectively including mint(), timestamp manipulation |
Threat Table
| Threat | Actor | Impact | Likelihood | Mitigation |
|---|---|---|---|---|
| Pre-computed nonces | Miners | Critical | Low | 1-hr freshness; init() binds to blockhash(block.number − 1); _recent() expires at interval boundary |
| Nonce replay | Miners | High | Very Low | 3D unique pairIndex = nonceHash ^ blockHash; _hashes mapping permanent |
| Block hash selection gaming | Miners | Low | Medium | Any cached hash in current interval is valid; no advantage to choosing one over another |
| Double-claiming rewards | Stakers | High | Very Low | _claimed tracks cumulative; claimable() = rewardOf − claimed; nonReentrant guard |
| Flash-stake manipulation | Stakers | Medium | Low | Time-weighted _age += amount × block.timestamp; zero rewards for sub-second holding |
| Treasury drain via APOW unwrap | Stakers | Critical | Very Low | Rate limiter (~1/min mean); 43,830 cap; Banq pool lock |
| APR/APB abuse within bounds | Rate Setters | Medium | Medium | Rpp 0.5×–2.0×; 1-month cooldown; integrator dampens via weighted mean |
| Coordinated APR+APB change | Colluding Rate Setters | Medium | Low | Separate roles + admin; separate multi-sig signers |
| Reentrancy via ERC1155 callback | External Contracts | Critical | Low | ReentrancyGuardTransient (EIP-1153); atomic Banq modifier |
| Reentrancy via pool callback | Lending Pool | High | Low | Banq._supply() called after state updates; nonReentrant on entry points |
| Supply cap bypass | Stakers + Pool | Critical | Very Low | Hard cap in Banq ctor; pool assumes responsibility post-init(); pool independently audited |
| NFT metadata tampering | URI Manager | Medium | Medium | 10-year immalleability on year-specific URIs |
| Migration after deadline | Migrants | High | Very Low | _deadlineBy immutable; reverts past deadline; one-way seals |
| Premature migration seal | Seal Authority | Medium | Medium | One-way seal requires role auth; seal events emit for transparency |
| Legacy contract compromise | External Actor | Critical | Low | Burn-only interaction during migration; 4-year deadline limits exposure |
| Block hash censorship | Validators | Medium | Low | Any miner can init(); censorship affects all miners equally |
| Gas griefing on batch ops | Any | Low | Very Low | Bounded max iterations (102 NFTs); 15M gas ceiling sufficient |
| Timestamp manipulation (±30s) | Validators | Low | Very Low | Protocol uses hour+ granularity; 30s variance is negligible |
| Flash-loan backed supply attack | Stakers | High | Low | Banq 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 _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():
claimable()computesrewardOf() − claimed= 2.14 − 0 = 2.14 APOW_claimed[account][nftId] += 2.14(now 2.14)_sov.mint(account, 2.14)→ 2.14 APOW sent to staker_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:
function _pushMint(address account, uint256 nftId, uint256 amount) private {
_age[account][nftId] += amount * block.timestamp;
}The age contribution is 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 Power.raise(1e6, array[3]) where array[3] defaults to 256:
The denominator (
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
Avalanche consensus finality: Validators produce blocks with honest, non-predictable hashes. Censorship of
init()requires dropping allinit()transactions for an interval, which also prevents mining in that interval for all participants equally. No selective advantage is possible.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.
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.
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.
blockhashavailability: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.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 beforeinit()is called, andinit()is a one-time operation.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:
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 viatransferFrom— 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:
_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.