Skip to content

Commit

Permalink
Feat/multiple unstakes (#37)
Browse files Browse the repository at this point in the history
* feat: add ability for multiple concurrent unstakes

* all tests passing

* sequential test working

* adds better sorting to withdraw

* moar unit tests

* lin

* fix failing tests

* use memory when not modifying state

* refactor errors and naming of struct

* ensure optimizer is enabled

* spelling

* adds test for increasing the cooldown
  • Loading branch information
0xean authored May 2, 2024
1 parent a315834 commit 06aa4e1
Show file tree
Hide file tree
Showing 9 changed files with 707 additions and 51 deletions.
2 changes: 2 additions & 0 deletions foundry/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ ffi = true
ast = true
build_info = true
extra_output = ["storageLayout"]
optimizer = true
optimizer_runs = 20_000

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
109 changes: 96 additions & 13 deletions foundry/src/FoxStakingV1.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Own
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {StakingInfo} from "./StakingInfo.sol";
import {UnstakingRequest} from "./UnstakingRequest.sol";

contract FoxStakingV1 is
Initializable,
Expand All @@ -29,7 +30,11 @@ contract FoxStakingV1 is
uint256 amount,
string indexed runeAddress
);
event Unstake(address indexed account, uint256 amount);
event Unstake(
address indexed account,
uint256 amount,
uint256 cooldownExpiry
);
event Withdraw(address indexed account, uint256 amount);
event SetRuneAddress(
address indexed account,
Expand Down Expand Up @@ -162,23 +167,81 @@ contract FoxStakingV1 is
info.stakingBalance -= amount;
info.unstakingBalance += amount;

// Set or update the cooldown period
info.cooldownExpiry = block.timestamp + cooldownPeriod;
UnstakingRequest memory unstakingRequest = UnstakingRequest({
unstakingBalance: amount,
cooldownExpiry: block.timestamp + cooldownPeriod
});

emit Unstake(msg.sender, amount);
info.unstakingRequests.push(unstakingRequest); // append to the end of unstakingRequests array

emit Unstake(msg.sender, amount, unstakingRequest.cooldownExpiry);
}

/// @notice Withdraws FOX tokens - assuming there's anything to withdraw and unstake cooldown period has completed - else reverts
/// This has to be initiated by the user itself i.e msg.sender only, cannot be called by an address for another
function withdraw() external whenNotPaused whenWithdrawalsNotPaused {
/// @notice Allows a user to withdraw a specified claim by index
/// @param index The index of the claim to withdraw
function withdraw(
uint256 index
) public whenNotPaused whenWithdrawalsNotPaused {
StakingInfo storage info = stakingInfo[msg.sender];
require(
info.unstakingRequests.length > 0,
"No unstaking requests found"
);

require(info.unstakingRequests.length > index, "invalid index");

UnstakingRequest memory unstakingRequest = info.unstakingRequests[
index
];

require(
block.timestamp >= unstakingRequest.cooldownExpiry,
"Not cooled down yet"
);

require(info.unstakingBalance > 0, "Cannot withdraw 0");
require(block.timestamp >= info.cooldownExpiry, "Not cooled down yet");
uint256 withdrawAmount = info.unstakingBalance;
info.unstakingBalance = 0;
foxToken.safeTransfer(msg.sender, withdrawAmount);
emit Withdraw(msg.sender, withdrawAmount);
if (info.unstakingRequests.length > 1) {
// we have more elements in the array, so shift the last element to the index being withdrawn
// and then shorten the array by 1
info.unstakingRequests[index] = info.unstakingRequests[
info.unstakingRequests.length - 1
];
info.unstakingRequests.pop();
} else {
// the array is done, we can delete the whole thing
delete info.unstakingRequests;
}
info.unstakingBalance -= unstakingRequest.unstakingBalance;
foxToken.safeTransfer(msg.sender, unstakingRequest.unstakingBalance);
emit Withdraw(msg.sender, unstakingRequest.unstakingBalance);
}

/// @notice processes the most recent unstaking request available to the user, else reverts.
function withdraw() external {
StakingInfo memory info = stakingInfo[msg.sender];
uint256 length = info.unstakingRequests.length;
require(length > 0, "No unstaking requests found");
uint256 indexToProcess;
uint256 earliestCooldownExpiry = type(uint256).max;

for (uint256 i; i < length; i++) {
UnstakingRequest memory unstakingRequest = info.unstakingRequests[
i
];
if (block.timestamp >= unstakingRequest.cooldownExpiry) {
// this claim is ready to be processed
if (unstakingRequest.cooldownExpiry < earliestCooldownExpiry) {
// we found a more recent claim we can process.
earliestCooldownExpiry = unstakingRequest.cooldownExpiry;
indexToProcess = i;
}
}
}

require(
earliestCooldownExpiry != type(uint256).max,
"Not cooled down yet"
);
withdraw(indexToProcess);
}

/// @notice Allows a user to initially set (or update) their THORChain (RUNE) address for receiving staking rewards.
Expand All @@ -203,4 +266,24 @@ contract FoxStakingV1 is
StakingInfo memory info = stakingInfo[account];
return info.stakingBalance + info.unstakingBalance;
}

/// @notice helper function to access dynamic array nested in struct from external sources
/// @param account The address we're getting the unstaking request for.
/// @param index The index of the unstaking request array we're getting.
function getUnstakingRequest(
address account,
uint256 index
) external view returns (UnstakingRequest memory) {
return stakingInfo[account].unstakingRequests[index];
}

/// @notice returns the numbery of ustaking request elements for a given address
/// @dev useful for off chain processing
/// @param account The address we're getting the unstaking info count for.
/// @return length The number of unstaking request elements.
function getUnstakingRequestCount(
address account
) external view returns (uint256) {
return stakingInfo[account].unstakingRequests.length;
}
}
4 changes: 3 additions & 1 deletion foundry/src/StakingInfo.sol
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;

import {UnstakingRequest} from "./UnstakingRequest.sol";

struct StakingInfo {
uint256 stakingBalance;
uint256 unstakingBalance;
uint256 cooldownExpiry;
string runeAddress;
UnstakingRequest[] unstakingRequests;
}
7 changes: 7 additions & 0 deletions foundry/src/UnstakingRequest.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;

struct UnstakingRequest {
uint256 unstakingBalance;
uint256 cooldownExpiry;
}
2 changes: 1 addition & 1 deletion foundry/test/FOXStakingTestRuneAddress.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ contract FOXStakingTestRuneAddress is Test {

foxStaking.setRuneAddress(newRuneAddress);

(, , , string memory runeAddress) = foxStaking.stakingInfo(user);
(, , string memory runeAddress) = foxStaking.stakingInfo(user);
assertEq(
runeAddress,
newRuneAddress,
Expand Down
14 changes: 3 additions & 11 deletions foundry/test/FOXStakingTestStaking.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ contract FOXStakingTestStaking is Test {
(
uint256 stakingBalance_before,
uint256 unstakingBalance_before,
,

) = foxStaking.stakingInfo(user);
vm.assertEq(stakingBalance_before + unstakingBalance_before, 0);
Expand All @@ -69,7 +68,6 @@ contract FOXStakingTestStaking is Test {
(
uint256 stakingBalance_after,
uint256 unstakingBalance_after,
,

) = foxStaking.stakingInfo(user);
vm.assertEq(stakingBalance_after + unstakingBalance_after, 1000);
Expand Down Expand Up @@ -102,7 +100,6 @@ contract FOXStakingTestStaking is Test {
(
uint256 stakingBalance_before,
uint256 unstakingBalance_before,
,

) = foxStaking.stakingInfo(user);
vm.assertEq(stakingBalance_before + unstakingBalance_before, 0);
Expand All @@ -127,7 +124,6 @@ contract FOXStakingTestStaking is Test {
(
uint256 stakingBalance_after,
uint256 unstakingBalance_after,
,

) = foxStaking.stakingInfo(user);
vm.assertEq(stakingBalance_after + unstakingBalance_after, 1000);
Expand All @@ -143,7 +139,7 @@ contract FOXStakingTestStaking is Test {
vm.startPrank(user);

// Check user staking balances
(uint256 stakingBalance, uint256 unstakingBalance, , ) = foxStaking
(uint256 stakingBalance, uint256 unstakingBalance, ) = foxStaking
.stakingInfo(user);
vm.assertEq(stakingBalance + unstakingBalance, 0);
vm.assertEq(stakingBalance, 0);
Expand All @@ -157,7 +153,6 @@ contract FOXStakingTestStaking is Test {
(
uint256 stakingBalance_after,
uint256 unstakingBalance_after,
,

) = foxStaking.stakingInfo(user);
vm.assertEq(stakingBalance_after + unstakingBalance_after, 0);
Expand All @@ -173,7 +168,7 @@ contract FOXStakingTestStaking is Test {
vm.startPrank(user);

// Check user staking balances
(uint256 stakingBalance, uint256 unstakingBalance, , ) = foxStaking
(uint256 stakingBalance, uint256 unstakingBalance, ) = foxStaking
.stakingInfo(user);
vm.assertEq(stakingBalance + unstakingBalance, 0);
vm.assertEq(stakingBalance, 0);
Expand All @@ -187,7 +182,6 @@ contract FOXStakingTestStaking is Test {
(
uint256 stakingBalance_after,
uint256 unstakingBalance_after,
,

) = foxStaking.stakingInfo(user);
vm.assertEq(stakingBalance_after + unstakingBalance_after, 0);
Expand Down Expand Up @@ -231,9 +225,7 @@ contract FOXStakingTestStaking is Test {
assertEq(total, amounts[i]);

// Verify each user's rune address
(, , , string memory runeAddress) = foxStaking.stakingInfo(
users[i]
);
(, , string memory runeAddress) = foxStaking.stakingInfo(users[i]);
assertEq(runeAddress, runeAddresses[i]);
}
}
Expand Down
Loading

0 comments on commit 06aa4e1

Please sign in to comment.