Skip to content

Commit 2675cd8

Browse files
committed
Add Decay to stake amount
1 parent f64b5b4 commit 2675cd8

4 files changed

Lines changed: 473 additions & 53 deletions

File tree

src/QueryTypeStakerFactory.sol

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ contract QueryTypeStakerFactory is Ownable {
1414
/// @notice The token that will be used for staking.
1515
IERC20 public immutable STAKING_TOKEN;
1616

17+
/// @notice Address that receives fees and decayed stake from pools
18+
address public feeRecipient;
19+
1720
/// @notice Maps query type bit fields to their corresponding staking pool addresses.
1821
mapping(bytes32 queryType => address poolAddress) public queryTypePools;
1922

23+
/// @notice Emitted when fee recipient is updated
24+
event FeeRecipientUpdated(address indexed oldRecipient, address indexed newRecipient);
25+
2026
/// @notice Emitted when a new staking pool is created for a query type bit field.
2127
event CreateQueryTypeStakingPool(bytes32 indexed queryType, address indexed poolAddress);
2228

@@ -32,6 +38,7 @@ contract QueryTypeStakerFactory is Ownable {
3238
constructor(address _owner, address _stakingToken) Ownable(_owner) {
3339
if (_stakingToken == address(0)) revert QueryTypeStakerFactory__InvalidTokenAddress();
3440
STAKING_TOKEN = IERC20(_stakingToken);
41+
_setFeeRecipient(_owner);
3542
}
3643

3744
/// @notice Creates a new staking pool for a specific query type bit field.
@@ -49,12 +56,27 @@ contract QueryTypeStakerFactory is Ownable {
4956

5057
// Deploy new staking pool with STAKING_TOKEN address and initial conversion entry
5158
QueryTypeStakingPool _newPool =
52-
new QueryTypeStakingPool(_poolOwner, address(STAKING_TOKEN), _initialEntry);
59+
new QueryTypeStakingPool(_poolOwner, address(STAKING_TOKEN), address(this), _initialEntry);
5360
_poolAddress = address(_newPool);
5461

5562
// Store the pool address
5663
queryTypePools[_queryType] = _poolAddress;
5764

5865
emit CreateQueryTypeStakingPool(_queryType, _poolAddress);
5966
}
67+
68+
/// @notice Updates the fee recipient address.
69+
/// @param _newRecipient The new address to receive fees.
70+
function setFeeRecipient(address _newRecipient) external {
71+
_checkOwner();
72+
_setFeeRecipient(_newRecipient);
73+
}
74+
75+
/// @notice Internal function to update the fee recipient and emit an event.
76+
/// @param _newRecipient The new address to receive fees.
77+
function _setFeeRecipient(address _newRecipient) internal {
78+
address old = feeRecipient;
79+
feeRecipient = _newRecipient;
80+
emit FeeRecipientUpdated(old, _newRecipient);
81+
}
6082
}

src/QueryTypeStakingPool.sol

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pragma solidity 0.8.26;
44
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
55
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
66
import {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

@@ -208,7 +224,9 @@ contract QueryTypeStakingPool is Ownable {
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,44 @@ 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) return 0;
333+
334+
address _feeRecipient = QueryTypeStakerFactory(FACTORY).feeRecipient();
335+
if (_feeRecipient == address(0)) return 0;
336+
337+
if (decayed > stakeInfo.amount) decayed = stakeInfo.amount;
338+
339+
// Apply decay and update accounting
340+
stakeInfo.amount -= decayed;
341+
stakeInfo.decayed += decayed;
342+
stakeInfo.lastClaimed = uint48(block.timestamp);
343+
344+
STAKING_TOKEN.safeTransfer(_feeRecipient, decayed);
345+
346+
emit DecayClaimed(_staker, decayed, _feeRecipient);
347+
return decayed;
348+
}
286349
}

test/QueryTypeStakerFactory.t.sol

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ contract Constructor is QueryTypeStakerFactoryTest {
3333
QueryTypeStakerFactory newFactory = new QueryTypeStakerFactory(_owner, _stakingToken);
3434
assertEq(newFactory.owner(), _owner);
3535
assertEq(address(newFactory.STAKING_TOKEN()), _stakingToken);
36+
assertEq(newFactory.feeRecipient(), _owner);
3637
}
3738

3839
function testFuzz_RevertIf_StakingTokenAddressIsZero(address _owner) public {
@@ -41,6 +42,16 @@ contract Constructor is QueryTypeStakerFactoryTest {
4142
vm.expectRevert(QueryTypeStakerFactory.QueryTypeStakerFactory__InvalidTokenAddress.selector);
4243
new QueryTypeStakerFactory(_owner, address(0));
4344
}
45+
46+
function testFuzz_EmitsFeeRecipientUpdatedEventWithArbitraryOwner(
47+
address _owner,
48+
address _stakingToken
49+
) public {
50+
vm.assume(_owner != address(0) && _stakingToken != address(0));
51+
vm.expectEmit();
52+
emit QueryTypeStakerFactory.FeeRecipientUpdated(address(0), _owner);
53+
new QueryTypeStakerFactory(_owner, _stakingToken);
54+
}
4455
}
4556

4657
contract CreateStakingPool is QueryTypeStakerFactoryTest {
@@ -101,3 +112,31 @@ contract CreateStakingPool is QueryTypeStakerFactoryTest {
101112
vm.stopPrank();
102113
}
103114
}
115+
116+
contract SetFeeRecipient is QueryTypeStakerFactoryTest {
117+
function testFuzz_SetsFeeRecipientCorrectly(address _newRecipient) public {
118+
vm.assume(_newRecipient != address(0));
119+
vm.prank(owner);
120+
factory.setFeeRecipient(_newRecipient);
121+
assertEq(factory.feeRecipient(), _newRecipient);
122+
}
123+
124+
function testFuzz_RevertIf_CallerIsNotOwner(address _notOwner, address _newRecipient) public {
125+
vm.assume(_notOwner != owner && _notOwner != address(0));
126+
vm.assume(_newRecipient != address(0));
127+
128+
vm.prank(_notOwner);
129+
vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", _notOwner));
130+
factory.setFeeRecipient(_newRecipient);
131+
}
132+
133+
function testFuzz_EmitsFeeRecipientUpdatedEventWithArbitraryRecipient(address _newRecipient)
134+
public
135+
{
136+
vm.assume(_newRecipient != address(0));
137+
vm.expectEmit();
138+
emit QueryTypeStakerFactory.FeeRecipientUpdated(owner, _newRecipient);
139+
vm.prank(owner);
140+
factory.setFeeRecipient(_newRecipient);
141+
}
142+
}

0 commit comments

Comments
 (0)