Skip to content

Commit c65808c

Browse files
committed
feat: implement DECAY_RATE
1 parent a678ffd commit c65808c

4 files changed

Lines changed: 142 additions & 24 deletions

File tree

src/QueryTypeStakerFactory.sol

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,22 @@ contract QueryTypeStakerFactory is Ownable {
4848
/// @param _queryType The bit field representing the queries this pool will support.
4949
/// @param _poolOwner The address that will own the staking pool.
5050
/// @param _initialEntry The initial conversion table entry for the pool.
51+
/// @param _decayRate The decay rate for the stake.
5152
/// @return _poolAddress The address of the newly created staking pool.
5253
/// @dev Only callable by the contract owner.
53-
function createStakingPool(bytes32 _queryType, address _poolOwner, bytes32 _initialEntry)
54-
external
55-
returns (address _poolAddress)
56-
{
54+
function createStakingPool(
55+
bytes32 _queryType,
56+
address _poolOwner,
57+
bytes32 _initialEntry,
58+
uint8 _decayRate
59+
) external returns (address _poolAddress) {
5760
_checkOwner();
5861
if (queryTypePools[_queryType] != address(0)) revert QueryTypeStakerFactory__PoolExists();
5962

6063
// Deploy new staking pool with STAKING_TOKEN address and initial conversion entry
61-
QueryTypeStakingPool _newPool =
62-
new QueryTypeStakingPool(_poolOwner, address(STAKING_TOKEN), address(this), _initialEntry);
64+
QueryTypeStakingPool _newPool = new QueryTypeStakingPool(
65+
_poolOwner, address(STAKING_TOKEN), address(this), _initialEntry, _decayRate
66+
);
6367
_poolAddress = address(_newPool);
6468

6569
// Store the pool address

src/QueryTypeStakingPool.sol

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ import {QueryTypeStakerFactory} from "src/QueryTypeStakerFactory.sol";
1616
contract QueryTypeStakingPool is Ownable {
1717
using SafeERC20 for IERC20;
1818

19+
/// @notice The decay rate applied to stake amounts. This rate determines what
20+
/// percentage of the continuously decaying stake is lost as fees.
21+
/// @dev Expressed as an integer from 0 to 100:
22+
/// - 0: No decay (0% lost as fees)
23+
/// - 100: Complete decay (100% lost as fees)
24+
/// - 50: 50% decay rate (50% lost as fees)
25+
/// @dev The decay is applied continuously over time, with users losing stake at a rate
26+
/// proportional to the time elapsed since their last claim. The DECAY_RATE determines
27+
/// what portion of this decayed amount is lost as fees.
28+
uint8 public immutable DECAY_RATE;
29+
1930
/// @notice The duration in seconds that tokens will be locked after staking. During this period
2031
/// tokens cannot be withdrawn.
2132
uint48 public lockupPeriod = 30 days;
@@ -106,6 +117,9 @@ contract QueryTypeStakingPool is Ownable {
106117
/// @notice Emitted when decayed stake is claimed and forwarded to the fee recipient.
107118
event DecayClaimed(address indexed staker, uint256 amount, address indexed feeRecipient);
108119

120+
/// @notice Thrown when attempting to set a decay rate outside the allowed range.
121+
error QueryTypeStakingPool__InvalidDecayRate();
122+
109123
/// @notice Thrown when attempting to stake with an invalid lockup period.
110124
error QueryTypeStakingPool__LockupPeriodTooLow();
111125

@@ -149,15 +163,20 @@ contract QueryTypeStakingPool is Ownable {
149163
/// @param _stakingToken The address of the ERC20 token that will be staked.
150164
/// @param _factory The address of the factory that deployed this pool.
151165
/// @param _initialConversionTableEntry The first entry in the conversion table history.
166+
/// @param _decayRate The decay rate for the stake.
152167
constructor(
153168
address _owner,
154169
address _stakingToken,
155170
address _factory,
156-
bytes32 _initialConversionTableEntry
171+
bytes32 _initialConversionTableEntry,
172+
uint8 _decayRate
157173
) Ownable(_owner) {
158174
STAKING_TOKEN = IERC20(_stakingToken);
159175
FACTORY = _factory;
160176

177+
if (_decayRate > 100) revert QueryTypeStakingPool__InvalidDecayRate();
178+
DECAY_RATE = _decayRate;
179+
161180
// Initialize the conversion table with the provided entry
162181
conversionTableHistory.push(_initialConversionTableEntry);
163182
emit ConversionTableUpdated(_initialConversionTableEntry);
@@ -335,6 +354,12 @@ contract QueryTypeStakingPool is Ownable {
335354
if (totalPeriod == 0) return 0;
336355

337356
uint256 decayed = (stakeInfo.amount * elapsed) / totalPeriod;
357+
358+
// Apply proportional fee loss based on DECAY_RATE.
359+
// DECAY_RATE represents the % of the decayed amount that should be lost as fees.
360+
// Example: DECAY_RATE = 80 → lose 80% of the decayed amount as fees.
361+
decayed = (decayed * DECAY_RATE) / 100;
362+
338363
if (decayed == 0) return 0;
339364

340365
if (decayed > stakeInfo.amount) decayed = stakeInfo.amount;

test/QueryTypeStakerFactory.t.sol

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: Apache-2.0
22
pragma solidity 0.8.26;
33

4-
import {Test, console2} from "forge-std/Test.sol";
4+
import {Test} from "forge-std/Test.sol";
55
import {QueryTypeStakerFactory} from "src/QueryTypeStakerFactory.sol";
66
import {QueryTypeStakingPool} from "src/QueryTypeStakingPool.sol";
77
import {VmSafe} from "forge-std/Vm.sol";
@@ -20,9 +20,8 @@ contract QueryTypeStakerFactoryTest is Test {
2020
queryType = bytes32(uint256(1));
2121
}
2222

23-
function _createPool() internal returns (address) {
24-
vm.prank(owner);
25-
return factory.createStakingPool(queryType, owner, bytes32(0));
23+
function _assumeSafeDecayRate(uint8 _decayRate) internal pure returns (uint8) {
24+
return uint8(bound(_decayRate, 0, 100));
2625
}
2726
}
2827

@@ -58,26 +57,33 @@ contract CreateStakingPool is QueryTypeStakerFactoryTest {
5857
function testFuzz_CreatesNewStakingPoolWithArbitraryQueryType(
5958
bytes32 _queryType,
6059
address _poolOwner,
61-
bytes32 _initialEntry
60+
bytes32 _initialEntry,
61+
uint8 _decayRate
6262
) public {
63+
_decayRate = _assumeSafeDecayRate(_decayRate);
6364
vm.assume(_poolOwner != address(0));
6465
vm.prank(owner);
65-
address poolAddress = factory.createStakingPool(_queryType, _poolOwner, _initialEntry);
66+
address poolAddress =
67+
factory.createStakingPool(_queryType, _poolOwner, _initialEntry, _decayRate);
6668

6769
assertTrue(poolAddress != address(0));
6870
assertEq(factory.queryTypePools(_queryType), poolAddress);
6971
assertEq(QueryTypeStakingPool(poolAddress).owner(), _poolOwner);
72+
assertEq(QueryTypeStakingPool(poolAddress).DECAY_RATE(), _decayRate);
7073
}
7174

7275
function testFuzz_EmitsCreateQueryTypeStakingPoolEventWithArbitraryQueryType(
7376
bytes32 _queryType,
7477
address _poolOwner,
75-
bytes32 _initialEntry
78+
bytes32 _initialEntry,
79+
uint8 _decayRate
7680
) public {
81+
_decayRate = _assumeSafeDecayRate(_decayRate);
7782
vm.assume(_poolOwner != address(0));
7883
vm.recordLogs();
7984
vm.prank(owner);
80-
address poolAddress = factory.createStakingPool(_queryType, _poolOwner, _initialEntry);
85+
address poolAddress =
86+
factory.createStakingPool(_queryType, _poolOwner, _initialEntry, _decayRate);
8187

8288
VmSafe.Log[] memory entries = vm.getRecordedLogs();
8389
assertEq(entries[2].topics[0], keccak256("CreateQueryTypeStakingPool(bytes32,address)"));
@@ -88,27 +94,31 @@ contract CreateStakingPool is QueryTypeStakerFactoryTest {
8894
function testFuzz_RevertIf_CallerIsNotOwner(
8995
address _notOwner,
9096
address _poolOwner,
91-
bytes32 _initialEntry
97+
bytes32 _initialEntry,
98+
uint8 _decayRate
9299
) public {
100+
_decayRate = _assumeSafeDecayRate(_decayRate);
93101
vm.assume(_notOwner != owner && _notOwner != address(0));
94102
vm.assume(_poolOwner != address(0));
95103

96104
vm.prank(_notOwner);
97105
vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", _notOwner));
98-
factory.createStakingPool(queryType, _poolOwner, _initialEntry);
106+
factory.createStakingPool(queryType, _poolOwner, _initialEntry, _decayRate);
99107
}
100108

101109
function testFuzz_RevertIf_PoolAlreadyExistsWithArbitraryQueryType(
102110
bytes32 _queryType,
103111
address _poolOwner,
104-
bytes32 _initialEntry
112+
bytes32 _initialEntry,
113+
uint8 _decayRate
105114
) public {
115+
_decayRate = _assumeSafeDecayRate(_decayRate);
106116
vm.assume(_poolOwner != address(0));
107117
vm.startPrank(owner);
108-
factory.createStakingPool(_queryType, _poolOwner, _initialEntry);
118+
factory.createStakingPool(_queryType, _poolOwner, _initialEntry, _decayRate);
109119

110120
vm.expectRevert(QueryTypeStakerFactory.QueryTypeStakerFactory__PoolExists.selector);
111-
factory.createStakingPool(_queryType, _poolOwner, _initialEntry);
121+
factory.createStakingPool(_queryType, _poolOwner, _initialEntry, _decayRate);
112122
vm.stopPrank();
113123
}
114124
}

test/QueryTypeStakingPool.t.sol

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ contract QueryTypeStakingPoolTest is Test {
2525

2626
factory.setFeeRecipient(feeRecipient);
2727

28-
bytes32 queryType = bytes32(uint256(1));
29-
bytes32 initialEntry = bytes32(uint256(1));
30-
address poolAddress = factory.createStakingPool(queryType, address(this), initialEntry);
28+
address poolAddress = factory.createStakingPool(
29+
bytes32(uint256(1)), // queryType
30+
address(this), // poolOwner
31+
bytes32(uint256(1)), // initialEntry
32+
100 // decayRate (0% retained, 100% becomes fees)
33+
);
3134
pool = QueryTypeStakingPool(poolAddress);
3235

3336
stakingToken.mint(staker, INITIAL_BALANCE);
@@ -61,10 +64,23 @@ contract Constructor is QueryTypeStakingPoolTest {
6164
vm.assume(_stakingToken != address(0));
6265

6366
QueryTypeStakingPool _newPool =
64-
new QueryTypeStakingPool(_owner, _stakingToken, address(factory), _initialEntry);
67+
new QueryTypeStakingPool(_owner, _stakingToken, address(factory), _initialEntry, 0);
6568
assertEq(address(_newPool.STAKING_TOKEN()), _stakingToken);
6669
assertEq(_newPool.conversionTableHistory(0), _initialEntry);
6770
}
71+
72+
function testFuzz_RevertIf_DecayRateIsInvalid(uint8 _decayRate) public {
73+
_decayRate = uint8(bound(_decayRate, 101, type(uint8).max));
74+
75+
vm.expectRevert(QueryTypeStakingPool.QueryTypeStakingPool__InvalidDecayRate.selector);
76+
new QueryTypeStakingPool(
77+
address(this), // owner
78+
address(stakingToken), // stakingToken
79+
address(factory), // factory
80+
bytes32(uint256(100)), // initialEntry
81+
_decayRate
82+
);
83+
}
6884
}
6985

7086
contract UpdateConversionTable is QueryTypeStakingPoolTest {
@@ -1029,4 +1045,67 @@ contract Claim is QueryTypeStakingPoolTest {
10291045
pool.totalCapacityStaked(), _stakeAmount, "Total staked should remain at original amount"
10301046
);
10311047
}
1048+
1049+
function testFuzz_DecayRateAppliedCorrectly(
1050+
uint8 _decayRate,
1051+
uint256 _stakeAmount,
1052+
uint32 _timeElapsed
1053+
) public {
1054+
// Bound parameters to reasonable ranges
1055+
_decayRate = uint8(bound(_decayRate, 1, 100)); // Valid decay rates only
1056+
_stakeAmount = bound(_stakeAmount, 100 ether, 10_000 ether);
1057+
_timeElapsed = uint32(bound(_timeElapsed, 1 days, 60 days));
1058+
1059+
address poolAddress = factory.createStakingPool(
1060+
bytes32(uint256(5)), // queryType
1061+
address(this), // poolOwner
1062+
bytes32(uint256(100)), // initialEntry
1063+
_decayRate // fuzzed decay rate
1064+
);
1065+
QueryTypeStakingPool fuzzedPool = QueryTypeStakingPool(poolAddress);
1066+
1067+
// Set capacity based on stake amount
1068+
uint256 capacity = bound(10_000 ether, _stakeAmount, _stakeAmount * 10);
1069+
fuzzedPool.setStakingTokenCapacity(capacity);
1070+
1071+
// Setup staker
1072+
stakingToken.mint(staker, INITIAL_BALANCE);
1073+
vm.prank(staker);
1074+
stakingToken.approve(address(fuzzedPool), type(uint256).max);
1075+
1076+
// Stake tokens
1077+
vm.prank(staker);
1078+
fuzzedPool.stake(_stakeAmount);
1079+
1080+
uint256 initialAmount = _stakeAmount;
1081+
1082+
// Get stake info to calculate expected decay
1083+
(uint256 amount,,, uint48 accessEnd, uint48 lastClaimed,) = fuzzedPool.stakes(staker);
1084+
uint256 totalPeriod = accessEnd - lastClaimed;
1085+
1086+
// Calculate expected decay using the same formula as the contract
1087+
uint256 elapsed = _timeElapsed;
1088+
uint256 decayed = (amount * elapsed) / totalPeriod;
1089+
decayed = (decayed * _decayRate) / 100;
1090+
1091+
// Cap decay at the total amount
1092+
if (decayed > amount) decayed = amount;
1093+
1094+
uint256 expectedFinalAmount = amount - decayed;
1095+
1096+
// Advance time
1097+
vm.warp(block.timestamp + _timeElapsed);
1098+
1099+
// Claim decay
1100+
fuzzedPool.claim(staker);
1101+
1102+
// Verify decay behavior with exact values
1103+
(uint256 finalAmount,,,,,) = fuzzedPool.stakes(staker);
1104+
1105+
assertEq(
1106+
finalAmount, expectedFinalAmount, "Final amount should match expected decay calculation"
1107+
);
1108+
assertLe(finalAmount, initialAmount, "Amount should not increase with decay");
1109+
assertGe(finalAmount, 0, "Amount should not go negative");
1110+
}
10321111
}

0 commit comments

Comments
 (0)