@@ -4,6 +4,7 @@ pragma solidity 0.8.26;
44import {IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol " ;
55import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol " ;
66import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol " ;
7+ import {QueryTypeStakerFactory} from "src/QueryTypeStakerFactory.sol " ;
78
89/// @title QueryTypeStakingPool
910/// @author ScopeLift
@@ -29,6 +30,9 @@ contract QueryTypeStakingPool is Ownable {
2930 /// @notice The ERC20 token contract that can be staked in this pool.
3031 IERC20 public immutable STAKING_TOKEN;
3132
33+ /// @notice Address of the factory that deployed this pool. Provides the feeRecipient.
34+ address public immutable FACTORY;
35+
3236 /// @notice A struct containing information about a user's stake, including the amount staked, the
3337 /// index into the conversion table history at time of staking, and the lockup/access period end
3438 /// times.
@@ -37,6 +41,8 @@ contract QueryTypeStakingPool is Ownable {
3741 uint256 conversionTableIndex;
3842 uint48 lockupEnd;
3943 uint48 accessEnd;
44+ uint48 lastClaimed;
45+ uint256 decayed;
4046 }
4147
4248 /// @notice A mapping that associates staker addresses with their stake information.
@@ -97,6 +103,9 @@ contract QueryTypeStakingPool is Ownable {
97103 /// @notice Emitted when an address is blocklisted for this pool
98104 event AddressBlocklisted (address indexed user );
99105
106+ /// @notice Emitted when decayed stake is claimed and forwarded to the fee recipient.
107+ event DecayClaimed (address indexed staker , uint256 amount , address indexed feeRecipient );
108+
100109 /// @notice Thrown when attempting to stake with an invalid lockup period.
101110 error QueryTypeStakingPool__LockupPeriodTooLow ();
102111
@@ -138,11 +147,16 @@ contract QueryTypeStakingPool is Ownable {
138147 /// @param _owner The address that will own the contract and have permission to update the
139148 /// conversion table.
140149 /// @param _stakingToken The address of the ERC20 token that will be staked.
150+ /// @param _factory The address of the factory that deployed this pool.
141151 /// @param _initialConversionTableEntry The first entry in the conversion table history.
142- constructor (address _owner , address _stakingToken , bytes32 _initialConversionTableEntry )
143- Ownable (_owner)
144- {
152+ constructor (
153+ address _owner ,
154+ address _stakingToken ,
155+ address _factory ,
156+ bytes32 _initialConversionTableEntry
157+ ) Ownable (_owner) {
145158 STAKING_TOKEN = IERC20 (_stakingToken);
159+ FACTORY = _factory;
146160
147161 // Initialize the conversion table with the provided entry
148162 conversionTableHistory.push (_initialConversionTableEntry);
@@ -192,6 +206,8 @@ contract QueryTypeStakingPool is Ownable {
192206 /// @notice Allows users to stake tokens for the predefined lockup and access periods.
193207 /// @param _amount The amount of tokens to stake.
194208 function stake (uint256 _amount ) external {
209+ _claimDecay (msg .sender );
210+
195211 if (_amount < minimumStake) revert QueryTypeStakingPool__AmountBelowMinimum ();
196212 if (isBlocklisted[msg .sender ]) revert QueryTypeStakingPool__AddressBlocklisted ();
197213
@@ -200,15 +216,17 @@ contract QueryTypeStakingPool is Ownable {
200216 }
201217
202218 // Reset lockup and access periods
203- StakeInfo memory stakeInfo = stakes[msg .sender ];
219+ StakeInfo storage stakeInfo = stakes[msg .sender ];
204220 stakeInfo.lockupEnd = uint48 (block .timestamp ) + lockupPeriod;
205221 stakeInfo.accessEnd = stakeInfo.lockupEnd + accessPeriod;
206222 totalStaked += _amount;
207223
208224 if (stakeInfo.amount == 0 ) {
209225 // First-time stake
210226 stakeInfo.amount = _amount;
227+ stakeInfo.decayed = 0 ;
211228 stakeInfo.conversionTableIndex = conversionTableHistory.length - 1 ;
229+ stakeInfo.lastClaimed = uint48 (block .timestamp );
212230 stakes[msg .sender ] = stakeInfo;
213231 STAKING_TOKEN.safeTransferFrom (msg .sender , address (this ), _amount);
214232 emit Staked (
@@ -222,6 +240,7 @@ contract QueryTypeStakingPool is Ownable {
222240 }
223241
224242 stakeInfo.amount += _amount;
243+ stakeInfo.lastClaimed = uint48 (block .timestamp );
225244 stakes[msg .sender ] = stakeInfo;
226245
227246 STAKING_TOKEN.safeTransferFrom (msg .sender , address (this ), _amount);
@@ -241,13 +260,17 @@ contract QueryTypeStakingPool is Ownable {
241260 /// @param _amount The amount of tokens the user wishes to unstake.
242261 function unstake (uint256 _amount ) external {
243262 StakeInfo storage userStake = stakes[msg .sender ];
263+
264+ _claimDecay (msg .sender );
265+
244266 if (userStake.amount == 0 ) revert QueryTypeStakingPool__NoStakeFound ();
245267 if (block .timestamp < userStake.lockupEnd) revert QueryTypeStakingPool__StillInLockupPeriod ();
246268 if (_amount > userStake.amount) revert QueryTypeStakingPool__InsufficientBalance ();
247269
248270 if (isBlocklisted[msg .sender ]) totalJailed -= _amount;
249271 else totalStaked -= _amount;
250272
273+ userStake.decayed = 0 ;
251274 userStake.amount -= _amount;
252275 STAKING_TOKEN.safeTransfer (msg .sender , _amount);
253276
@@ -283,4 +306,52 @@ contract QueryTypeStakingPool is Ownable {
283306 isBlocklisted[_user] = true ;
284307 emit AddressBlocklisted (_user);
285308 }
309+
310+ /// @notice Claims the decayed portion of the caller's stake and forwards it to the feeRecipient.
311+ /// Any address can call this function on behalf of a staker.
312+ /// @param _staker The address whose decayed stake should be claimed. If omitted, defaults to
313+ /// msg.sender.
314+ function claim (address _staker ) public {
315+ _claimDecay (_staker);
316+ }
317+
318+ /// @notice Internal helper that settles the decayed portion of a stake.
319+ /// @param _staker The address whose decayed stake should be claimed.
320+ /// @return _claimed The amount of decayed stake claimed.
321+ function _claimDecay (address _staker ) internal returns (uint256 _claimed ) {
322+ StakeInfo storage stakeInfo = stakes[_staker];
323+ if (stakeInfo.amount == 0 ) return 0 ;
324+
325+ uint256 elapsed = block .timestamp - stakeInfo.lastClaimed;
326+ if (elapsed == 0 ) return 0 ;
327+
328+ uint256 totalPeriod = stakeInfo.accessEnd - stakeInfo.lastClaimed;
329+ if (totalPeriod == 0 ) return 0 ;
330+
331+ uint256 decayed = (stakeInfo.amount * elapsed) / totalPeriod;
332+ if (decayed == 0 ) {
333+ stakeInfo.lastClaimed = uint48 (block .timestamp );
334+ return 0 ;
335+ }
336+
337+ if (decayed > stakeInfo.amount) decayed = stakeInfo.amount;
338+
339+ address _feeRecipient = QueryTypeStakerFactory (FACTORY).feeRecipient ();
340+
341+ // If there is no fee recipient yet, update lastClaimed and exit without modifying
342+ if (_feeRecipient == address (0 )) {
343+ stakeInfo.lastClaimed = uint48 (block .timestamp );
344+ return 0 ;
345+ }
346+
347+ // Apply decay and update accounting
348+ stakeInfo.amount -= decayed;
349+ stakeInfo.decayed += decayed;
350+ stakeInfo.lastClaimed = uint48 (block .timestamp );
351+
352+ STAKING_TOKEN.safeTransfer (_feeRecipient, decayed);
353+
354+ emit DecayClaimed (_staker, decayed, _feeRecipient);
355+ return decayed;
356+ }
286357}
0 commit comments