F-2025-0005·missing-recovery-mechanism

Lack of unclaimed rewards recovery mechanism leads to permanent token lockup

Acknowledgednftstakingeip-712
TL;DR

Rewards inserted into the claim queue can sit indefinitely if the user never calls claim(). There is no expiration window and no admin path to recover or redistribute the locked balance.

Severity
LOW
Impact
LOW
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
LOW
Exploitability
LOW
02Section · Description

Description

The GenesisLicenseStaking contract lacks a mechanism to handle unclaimed rewards, which can lead to permanent token lockup and protocol inefficiency. When users stake tokens and generate rewards, these rewards are added to a claim queue after the unbonding period.

However, if users never execute the claim() function, these rewards remain in limbo indefinitely.

The issue stems from these key factors:

  1. Once rewards enter the claim queue through _insertIntoClaimQueue(), they can only be claimed by the designated user:
solidity
function _claim(uint256[] memory indexes_) internal returns (uint256) {
// ...
require($._userClaimQueueIds[user].contains(index), "Invalid Claim request");
// ...
}
  1. There is no expiration mechanism for unclaimed rewards:
solidity
// The claim is only checked against the unbonding period, not an expiration date
require(claimReq.endBlock <= block.number, "Claim request not expired");
  1. The contract permanently stores claim information even after fulfillment:
solidity
// Claims are marked as fulfilled but never deleted
$._claimQueueIds.remove(index_);
$._fulfilledClaimIds.add(index_);

This design leads to several issues:

  • MAT tokens allocated for rewards but never claimed are effectively removed from circulation.
  • Contract storage bloat due to accumulation of unclaimed and fulfilled claims.
  • Accounting discrepancies between distributed and claimed rewards.
  • No administrative mechanism to recover abandoned rewards.
03Section · Impact

Impact

Tokens reserved for rewards but never claimed are permanently locked, reducing the protocol's effective treasury. Storage bloat grows unboundedly as more claims accumulate.

04Section · Recommendation

Recommendation

  1. Implement a maximum claim period:
solidity
uint256 public constant MAX_CLAIM_PERIOD = BLOCKS_PER_DAY * 365; // 1 year expiration
function _claim(uint256[] memory indexes_) internal returns (uint256) {
// ...
ClaimReq memory claimReq = $._claimRequests[index];
require(claimReq.endBlock <= block.number, "Claim request not expired");
// Add expiration check
require(block.number <= claimReq.endBlock + MAX_CLAIM_PERIOD, "Claim expired");
// ...
}
  1. Add an administrative recovery function for expired claims:
solidity
function recoverExpiredClaims(uint256[] calldata indexes_) external onlyOwner {
for (uint256 i = 0; i < indexes_.length; ++i) {
uint256 index = indexes_[i];
require($._claimQueueIds.contains(index), "Invalid claim request");
ClaimReq memory claimReq = $._claimRequests[index];
require(block.number > claimReq.endBlock + MAX_CLAIM_PERIOD, "Claim not expired");
// Remove from claim queue
$._claimQueueIds.remove(index);
// Optional: transfer to community fund or redistribute
// IERC20($._mat).safeTransfer(communityFund, claimReq.amount);
emit ClaimExpired(index, claimReq.tokenId, claimReq.amount);
}
}
F-2025-0005

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx