diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3670a52 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.sol] +indent_style = space +indent_size = 4 +max_line_length = 120 +# Forge formatter typically puts opening braces on the same line: +curly_bracket_next_line = false +# Space after control statements like if, for, while: +spaces_around_operators = true +spaces_around_brackets = false +indent_brace_style = K&R \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..7444fe2 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,15 @@ +#!/bin/sh + +# Format all staged Solidity files using forge fmt +staged_sol_files=$(git diff --cached --name-only --diff-filter=ACMR | grep "\.sol$" || true) + +if [ -n "$staged_sol_files" ]; then + echo "Formatting Solidity files with forge fmt..." + # Format all staged Solidity files + forge fmt $staged_sol_files + + # Add the formatted files back to the staging area + git add $staged_sol_files +fi + +exit 0 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..96f3fcd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "makefile.configureOnOpen": false, + "editor.formatOnSave": true, + "[solidity]": { + "editor.formatOnSave": true + }, + "solidity.formatter": "forge" +} diff --git a/README.md b/README.md index efbbd8e..dac88fe 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,48 @@ LiteVault Owner can change the Authorizer contract, which will enable a grace wi Authorizer contract that authorize withdrawal regardless of token and amount, but only outside of the time range specified on deployment. +## Development Setup + +### Code Formatting + +This project uses `forge fmt` for consistent Solidity code formatting. We follow an editor-agnostic approach with additional convenience settings for VS Code users. + +#### Editor-Agnostic Formatting + +1. **Git Hooks**: The project includes pre-commit hooks that automatically format Solidity files using `forge fmt` before each commit. + + To set up the pre-commit hook: + + ```bash + # Configure git to use the hooks in the .githooks directory + git config core.hooksPath .githooks + ``` + +2. **EditorConfig**: The project includes an `.editorconfig` file with basic formatting rules that many editors support. + + To use these settings: + + - Install an EditorConfig plugin for your editor if it doesn't have built-in support + - The plugin will automatically apply basic formatting rules (indentation, line endings, etc.) + + More information about EditorConfig can be found at [https://editorconfig.org/](https://editorconfig.org/) + +#### VS Code-Specific Settings + +For VS Code users, additional settings are provided in `.vscode/settings.json` that: + +- Configure VS Code to use `forge fmt` automatically when saving Solidity files +- Ensure consistent formatting directly in the editor + +These settings are optional and only apply to VS Code users. Other editors may need their own configuration to exactly match `forge fmt` behavior. + +#### Recommended Workflow + +The recommended workflow for all developers, regardless of editor: + +1. Use the pre-commit hooks to ensure consistent formatting in the repository +2. If needed, run `forge fmt` manually before committing to see changes + ## Deployment and interaction This repository uses Foundry toolchain for development, testing and deployment. diff --git a/lib/forge-std b/lib/forge-std index e04104a..4d63c97 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit e04104ab93e771441eab03fb76eda1402cb5927b +Subproject commit 4d63c978718517fa02d4e330fbe7372dbb06c2f1 diff --git a/src/interfaces/IAuthorize.sol b/src/interfaces/IAuthorize.sol index 3effd68..70feb6c 100644 --- a/src/interfaces/IAuthorize.sol +++ b/src/interfaces/IAuthorize.sol @@ -14,6 +14,8 @@ interface IAuthorize { */ error Unauthorized(address user, address token, uint256 amount); + // NOTE: `view` modifier was removed to allow for better flexibility of authorizer contracts. + // On the other hand, Vault logic has not been changed to allow for compatibility with already deployed contracts. /** * @dev Authorizes actions based on the owner, token, and amount. * @param owner The address of the token owner. diff --git a/src/vault/LiteVault.sol b/src/vault/LiteVault.sol index 2aef9f1..034eb40 100644 --- a/src/vault/LiteVault.sol +++ b/src/vault/LiteVault.sol @@ -74,6 +74,9 @@ contract LiteVault is IVault, IAuthorizable, ReentrancyGuard, Ownable2Step { emit AuthorizerChanged(newAuthorizer); } + // TODO: add `customData` as parameter + // TODO: add a call to authorizer for better flexibility + // TODO: change `msg.sender` to `account` for more flexibility /** * @dev Deposits tokens or ETH into the vault. * @param token The address of the token to deposit. Use address(0) for ETH. @@ -92,6 +95,7 @@ contract LiteVault is IVault, IAuthorizable, ReentrancyGuard, Ownable2Step { emit Deposited(msg.sender, token, amount); } + // TODO: add `customData` as parameter /** * @dev Withdraws tokens or ETH from the vault. * @param token The address of the token to withdraw. Use address(0) for ETH. @@ -103,6 +107,7 @@ contract LiteVault is IVault, IAuthorizable, ReentrancyGuard, Ownable2Step { revert InsufficientBalance(token, amount, currentBalance); } if ( + // TODO: change method signature to pass `data` as a parameter, that an authorizer can decode and make a decision !_isWithdrawalGracePeriodActive( latestSetAuthorizerTimestamp, uint64(block.timestamp), WITHDRAWAL_GRACE_PERIOD ) && !authorizer.authorize(msg.sender, token, amount) diff --git a/src/vault/UnbondingPeriodAuthorizer.sol b/src/vault/UnbondingPeriodAuthorizer.sol new file mode 100644 index 0000000..c3c47ea --- /dev/null +++ b/src/vault/UnbondingPeriodAuthorizer.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IAuthorize} from "../interfaces/IAuthorize.sol"; + +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +/** + * @title UnbondingPeriodAuthorizer + * @notice Authorizer contract that enforces an unbonding period before withdrawals. + * @dev Users must request a withdrawal which starts an unbonding period. + * After the unbonding period has passed, the withdrawal is authorized. + * Supports multiple unbonding periods that can be enabled/disabled by the owner. + */ +contract UnbondingPeriodAuthorizer is IAuthorize, Ownable2Step { + // Use EnumerableSet for tracking supported unbonding periods + using EnumerableSet for EnumerableSet.UintSet; + + /** + * @notice Error thrown when the unbonding period has not yet passed. + * @param requestTimestamp The timestamp when the withdrawal was requested. + * @param currentTimestamp The current timestamp. + * @param unbondingPeriod The required unbonding period. + */ + error UnbondingPeriodNotExpired(uint64 requestTimestamp, uint64 currentTimestamp, uint64 unbondingPeriod); + + /** + * @notice Error thrown when the withdrawal has already been requested. + * @param user The address of the user. + * @param token The address of the token. + */ + error UnbondingAlreadyRequested(address user, address token); + + /** + * @notice Error thrown when the withdrawal has not been requested. + * @param user The address of the user. + * @param token The address of the token. + */ + error UnbondingNotRequested(address user, address token); + + /** + * @notice Error thrown when the requested unbonding period is not supported. + * @param unbondingPeriod The unbonding period that was requested. + */ + error UnsupportedUnbondingPeriod(uint64 unbondingPeriod); + + /** + * @notice Error thrown when the unbonding period is invalid. + */ + error InvalidUnbondingPeriod(); + + /** + * @notice Event emitted when a withdrawal is requested. + * @param user The address of the user requesting the withdrawal. + * @param token The address of the token to withdraw. + * @param unbondingPeriod The unbonding period chosen for this withdrawal request. + * @param unbondingTimestamp The timestamp when the unbonding period will expire. + */ + event UnbondingRequested( + address indexed user, address indexed token, uint64 unbondingPeriod, uint64 unbondingTimestamp + ); + + /** + * @notice Event emitted when an unbonding period has passed and the withdrawal is authorized. + * @param user The address of the user completing the withdrawal. + * @param token The address of the token being withdrawn. + */ + event UnbondingCompleted(address indexed user, address indexed token); + + /** + * @notice Event emitted when an unbonding period's status is updated. + * @param unbondingPeriod The unbonding period that was updated. + * @param isSupported Whether the unbonding period is now supported. + */ + event UnbondingPeriodStatusChanged(uint64 unbondingPeriod, bool isSupported); + + /** + * @notice Struct to store withdrawal request information. + * @param requestTimestamp The timestamp when the withdrawal was requested. + * @param unbondingPeriod The unbonding period chosen for this withdrawal request. + */ + struct UnbondingRequest { + uint64 requestTimestamp; + uint64 unbondingPeriod; + } + + // Set of all supported unbonding periods + EnumerableSet.UintSet internal _supportedUnbondingPeriods; + + // Mapping of user address to token address to withdrawal request + mapping(address user => mapping(address token => UnbondingRequest request)) internal _unbondingRequests; + + /** + * @dev Constructor sets the initial owner of the contract and enables the provided unbonding periods. + * @param owner The address of the owner. + * @param supportedUnbondingPeriods Array of unbonding periods to be initially supported. + */ + constructor(address owner, uint64[] memory supportedUnbondingPeriods) Ownable(owner) { + for (uint256 i = 0; i < supportedUnbondingPeriods.length; i++) { + uint64 period = supportedUnbondingPeriods[i]; + require(period > 0, InvalidUnbondingPeriod()); + _supportedUnbondingPeriods.add(period); + emit UnbondingPeriodStatusChanged(period, true); + } + } + + /** + * @notice Checks if an unbonding period is supported. + * @param unbondingPeriod The unbonding period to check. + * @return True if the unbonding period is supported, false otherwise. + */ + function isUnbondingPeriodSupported(uint64 unbondingPeriod) external view returns (bool) { + return _supportedUnbondingPeriods.contains(unbondingPeriod); + } + + /** + * @notice Get all supported unbonding periods. + * @return An array of all supported unbonding periods. + */ + function getAllSupportedUnbondingPeriods() external view returns (uint256[] memory) { + return _supportedUnbondingPeriods.values(); + } + + /** + * @notice Get the withdrawal request details for a user and token. + * @param user The address of the user. + * @param token The address of the token. + * @return requestTimestamp The timestamp when the withdrawal was requested. + * @return unbondingPeriod The unbonding period chosen for this withdrawal request. + */ + function getUnbondingRequest(address user, address token) + external + view + returns (uint64 requestTimestamp, uint64 unbondingPeriod) + { + UnbondingRequest memory request = _unbondingRequests[user][token]; + return (request.requestTimestamp, request.unbondingPeriod); + } + + /** + * @notice Check if a user has an active unbonding request for a token. + * @param user The address of the user. + * @param token The address of the token. + * @return True if the user has an active unbonding request, false otherwise. + */ + function hasActiveUnbondingRequest(address user, address token) external view returns (bool) { + return _unbondingRequests[user][token].requestTimestamp != 0; + } + + /** + * @notice Check if a withdrawal is authorized. + * @dev Returns true if the unbonding period has passed since the withdrawal request. + * @param owner The address of the token owner. + * @param token The address of the token. + * @return True if the withdrawal is authorized, false otherwise. + */ + function authorize( + address owner, + address token, + uint256 // amount - not used + ) public view override returns (bool) { + UnbondingRequest memory request = _unbondingRequests[owner][token]; + + // Check if withdrawal was requested + require(request.requestTimestamp != 0, UnbondingNotRequested(owner, token)); + + // Check if unbonding period has passed + // Note: We don't check if the unbonding period is still supported + require( + uint64(block.timestamp) >= request.requestTimestamp + request.unbondingPeriod, + UnbondingPeriodNotExpired(request.requestTimestamp, uint64(block.timestamp), request.unbondingPeriod) + ); + + return true; + } + + /** + * @notice Updates the status of an unbonding period. + * @param unbondingPeriod The unbonding period to update. + * @param isSupported Whether the unbonding period should be supported. + */ + function setUnbondingPeriodStatus(uint64 unbondingPeriod, bool isSupported) external onlyOwner { + require(unbondingPeriod > 0, InvalidUnbondingPeriod()); + + if (isSupported) { + _supportedUnbondingPeriods.add(unbondingPeriod); + } else { + _supportedUnbondingPeriods.remove(unbondingPeriod); + } + + emit UnbondingPeriodStatusChanged(unbondingPeriod, isSupported); + } + + /** + * @notice Request a withdrawal for a specific token with a specific unbonding period. + * @dev Emits a UnbondingRequested event. + * @param token The address of the token to withdraw. + * @param unbondingPeriod The unbonding period to use for this withdrawal request. + */ + function requestUnbonding(address token, uint64 unbondingPeriod) public { + require(_supportedUnbondingPeriods.contains(unbondingPeriod), UnsupportedUnbondingPeriod(unbondingPeriod)); + + require( + _unbondingRequests[msg.sender][token].requestTimestamp == 0, UnbondingAlreadyRequested(msg.sender, token) + ); + + address account = msg.sender; + _unbondingRequests[account][token] = + UnbondingRequest({requestTimestamp: uint64(block.timestamp), unbondingPeriod: unbondingPeriod}); + + emit UnbondingRequested(account, token, unbondingPeriod, uint64(block.timestamp) + unbondingPeriod); + } + + /** + * @notice Completes an unbonding request after the unbonding period has passed. + * @dev Verifies the unbonding period has passed before completing the request. + * It cleans up the request state and emits the UnbondingCompleted event. + * @param token The address of the token for which to complete the unbonding request. + */ + function completeUnbondingRequest(address token) external { + _completeUnbondingRequest(msg.sender, token); + } + + /** + * @notice Completes multiple unbonding requests in a single transaction after their unbonding periods have passed. + * @dev Verifies the unbonding period has passed for each token before completing the request. + * Will revert if any of the requests is not authorized (unbonding period not passed). + * @param tokens Array of token addresses for which to complete unbonding requests. + */ + function completeUnbondingRequests(address[] calldata tokens) external { + address account = msg.sender; + for (uint256 i = 0; i < tokens.length; i++) { + address token = tokens[i]; + _completeUnbondingRequest(account, token); + } + } + + /** + * @dev Internal helper function to complete an unbonding request for a specific user and token. + * It verifies the unbonding period has passed, deletes the request from storage and emits the UnbondingCompleted event. + * @param account The address of the user who made the unbonding request. + * @param token The address of the token for which to complete the unbonding request. + */ + function _completeUnbondingRequest(address account, address token) internal { + // Verify the unbonding period has passed + // NOTE: authorization amount does not matter here + authorize(account, token, 0); + delete _unbondingRequests[account][token]; + emit UnbondingCompleted(account, token); + } +} diff --git a/test/vault/UnbondingPeriodAuthorizerTest.t.sol b/test/vault/UnbondingPeriodAuthorizerTest.t.sol new file mode 100644 index 0000000..c5c5b43 --- /dev/null +++ b/test/vault/UnbondingPeriodAuthorizerTest.t.sol @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, Vm} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {UnbondingPeriodAuthorizer} from "../../src/vault/UnbondingPeriodAuthorizer.sol"; +import {TestERC20} from "../TestERC20.sol"; +import {IAuthorize} from "../../src/interfaces/IAuthorize.sol"; + +uint256 constant TIME = 1716051867; + +contract UnbondingPeriodAuthorizerTestBase is Test { + UnbondingPeriodAuthorizer authorizer; + TestERC20 token; + + address deployer = vm.createWallet("deployer").addr; + address owner = vm.createWallet("owner").addr; + address user = vm.createWallet("user").addr; + + uint64[] internal defaultUnbondingPeriods; + + function setUp() public virtual { + defaultUnbondingPeriods = new uint64[](3); + defaultUnbondingPeriods[0] = 1 days; + defaultUnbondingPeriods[1] = 7 days; + defaultUnbondingPeriods[2] = 30 days; + + vm.warp(TIME); + + vm.prank(deployer); + authorizer = new UnbondingPeriodAuthorizer(owner, defaultUnbondingPeriods); + + token = new TestERC20("Test", "TST", 18, type(uint256).max); + } +} + +contract UnbondingPeriodAuthorizerTest_constructor is UnbondingPeriodAuthorizerTestBase { + function test_constructor_setsCorrectOwner() public view { + assertEq(authorizer.owner(), owner); + } + + function test_constructor_setsSupportedUnbondingPeriods() public view { + uint256[] memory periods = authorizer.getAllSupportedUnbondingPeriods(); + assertEq(periods.length, defaultUnbondingPeriods.length); + + // Check each period is in the result + for (uint256 i = 0; i < defaultUnbondingPeriods.length; i++) { + bool found = false; + for (uint256 j = 0; j < periods.length; j++) { + if (periods[j] == defaultUnbondingPeriods[i]) { + found = true; + break; + } + } + assertTrue(found, "Period not found in result"); + } + } + + function test_constructor_emitsEventsForPeriods() public { + uint64[] memory periods = new uint64[](2); + periods[0] = 14 days; + periods[1] = 21 days; + + for (uint256 i = 0; i < periods.length; i++) { + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingPeriodStatusChanged(periods[i], true); + } + + vm.prank(deployer); + new UnbondingPeriodAuthorizer(owner, periods); + } + + function test_constructor_revert_ifZeroPeriod() public { + uint64[] memory periods = new uint64[](1); + periods[0] = 0; // Invalid period + + vm.expectRevert(abi.encodeWithSelector(UnbondingPeriodAuthorizer.InvalidUnbondingPeriod.selector)); + vm.prank(deployer); + new UnbondingPeriodAuthorizer(owner, periods); + } +} + +contract UnbondingPeriodAuthorizerTest_isUnbondingPeriodSupported is UnbondingPeriodAuthorizerTestBase { + function test_isUnbondingPeriodSupported_returnsTrue_forSupportedPeriod() public view { + assertTrue(authorizer.isUnbondingPeriodSupported(1 days)); + assertTrue(authorizer.isUnbondingPeriodSupported(7 days)); + assertTrue(authorizer.isUnbondingPeriodSupported(30 days)); + } + + function test_isUnbondingPeriodSupported_returnsFalse_forUnsupportedPeriod() public view { + assertFalse(authorizer.isUnbondingPeriodSupported(2 days)); + assertFalse(authorizer.isUnbondingPeriodSupported(14 days)); + assertFalse(authorizer.isUnbondingPeriodSupported(0)); + } +} + +contract UnbondingPeriodAuthorizerTest_getAllSupportedUnbondingPeriods is UnbondingPeriodAuthorizerTestBase { + function test_getAllSupportedUnbondingPeriods_returnsAllPeriods() public view { + uint256[] memory periods = authorizer.getAllSupportedUnbondingPeriods(); + + assertEq(periods.length, defaultUnbondingPeriods.length); + + // Check each period is in the result + for (uint256 i = 0; i < defaultUnbondingPeriods.length; i++) { + bool found = false; + for (uint256 j = 0; j < periods.length; j++) { + if (periods[j] == defaultUnbondingPeriods[i]) { + found = true; + break; + } + } + assertTrue(found, "Period not found in result"); + } + } + + function test_getAllSupportedUnbondingPeriods_returnsEmptyArray_forNoPeriods() public { + // Create authorizer with no periods + uint64[] memory noPeriods = new uint64[](0); + vm.prank(deployer); + UnbondingPeriodAuthorizer noPeriodsAuthorizer = new UnbondingPeriodAuthorizer(owner, noPeriods); + + uint256[] memory periods = noPeriodsAuthorizer.getAllSupportedUnbondingPeriods(); + assertEq(periods.length, 0); + } +} + +contract UnbondingPeriodAuthorizerTest_getUnbondingRequest is UnbondingPeriodAuthorizerTestBase { + function setUp() public override { + super.setUp(); + + vm.prank(user); + authorizer.requestUnbonding(address(token), 7 days); + } + + function test_getUnbondingRequest_returnsRequest_forExistingRequest() public view { + (uint64 requestTimestamp, uint64 unbondingPeriod) = authorizer.getUnbondingRequest(user, address(token)); + + assertEq(requestTimestamp, uint64(TIME)); + assertEq(unbondingPeriod, 7 days); + } + + function test_getUnbondingRequest_returnsZeros_forNonExistingRequest() public view { + (uint64 requestTimestamp, uint64 unbondingPeriod) = authorizer.getUnbondingRequest(user, address(0)); + + assertEq(requestTimestamp, 0); + assertEq(unbondingPeriod, 0); + } +} + +contract UnbondingPeriodAuthorizerTest_hasActiveUnbondingRequest is UnbondingPeriodAuthorizerTestBase { + function setUp() public override { + super.setUp(); + + vm.prank(user); + authorizer.requestUnbonding(address(token), 7 days); + } + + function test_hasActiveUnbondingRequest_returnsTrue_forExistingRequest() public view { + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token))); + } + + function test_hasActiveUnbondingRequest_returnsFalse_forNonExistingRequest() public view { + assertFalse(authorizer.hasActiveUnbondingRequest(user, address(0))); + assertFalse(authorizer.hasActiveUnbondingRequest(owner, address(token))); + } +} + +contract UnbondingPeriodAuthorizerTest_setUnbondingPeriodStatus is UnbondingPeriodAuthorizerTestBase { + function test_setUnbondingPeriodStatus_enablesNewPeriod_whenCalledByOwner() public { + uint64 newPeriod = 14 days; + + assertFalse(authorizer.isUnbondingPeriodSupported(newPeriod)); + + // Enable new period + vm.prank(owner); + authorizer.setUnbondingPeriodStatus(newPeriod, true); + + assertTrue(authorizer.isUnbondingPeriodSupported(newPeriod)); + } + + function test_setUnbondingPeriodStatus_disablesExistingPeriod_whenCalledByOwner() public { + uint64 existingPeriod = 7 days; + + assertTrue(authorizer.isUnbondingPeriodSupported(existingPeriod)); + + vm.prank(owner); + authorizer.setUnbondingPeriodStatus(existingPeriod, false); + + assertFalse(authorizer.isUnbondingPeriodSupported(existingPeriod)); + } + + function test_setUnbondingPeriodStatus_emitsEvent_whenPeriodEnabled() public { + uint64 newPeriod = 14 days; + + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingPeriodStatusChanged(newPeriod, true); + + vm.prank(owner); + authorizer.setUnbondingPeriodStatus(newPeriod, true); + } + + function test_setUnbondingPeriodStatus_emitsEvent_whenPeriodDisabled() public { + uint64 existingPeriod = 7 days; + + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingPeriodStatusChanged(existingPeriod, false); + + vm.prank(owner); + authorizer.setUnbondingPeriodStatus(existingPeriod, false); + } + + function test_setUnbondingPeriodStatus_revert_whenPeriodIsZero() public { + vm.expectRevert(abi.encodeWithSelector(UnbondingPeriodAuthorizer.InvalidUnbondingPeriod.selector)); + + vm.prank(owner); + authorizer.setUnbondingPeriodStatus(0, true); + } + + function test_setUnbondingPeriodStatus_revert_whenCallerIsNotOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + + vm.prank(user); + authorizer.setUnbondingPeriodStatus(14 days, true); + } +} + +contract UnbondingPeriodAuthorizerTest_requestUnbonding is UnbondingPeriodAuthorizerTestBase { + function test_requestUnbonding_createsRequest_forSupportedPeriod() public { + vm.prank(user); + authorizer.requestUnbonding(address(token), 7 days); + + (uint64 requestTimestamp, uint64 unbondingPeriod) = authorizer.getUnbondingRequest(user, address(token)); + + assertEq(requestTimestamp, uint64(TIME)); + assertEq(unbondingPeriod, 7 days); + } + + function test_requestUnbonding_emitsEvent() public { + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingRequested(user, address(token), 7 days, uint64(TIME) + 7 days); + + vm.prank(user); + authorizer.requestUnbonding(address(token), 7 days); + } + + function test_requestUnbonding_revert_whenRequestAlreadyMade() public { + // Initial request + vm.prank(user); + authorizer.requestUnbonding(address(token), 7 days); + + vm.warp(TIME + 1 days); + + // Request again + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingAlreadyRequested.selector, user, address(token)) + ); + authorizer.requestUnbonding(address(token), 7 days); + } + + function test_requestUnbonding_revert_whenPeriodIsUnsupported() public { + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnsupportedUnbondingPeriod.selector, uint64(14 days)) + ); + + vm.prank(user); + authorizer.requestUnbonding(address(token), 14 days); + } +} + +contract UnbondingPeriodAuthorizerTest_authorize is UnbondingPeriodAuthorizerTestBase { + uint64 unbondingPeriod = 7 days; + + function setUp() public override { + super.setUp(); + + vm.prank(user); + authorizer.requestUnbonding(address(token), unbondingPeriod); + } + + function test_authorize_returnsTrue_whenUnbondingPeriodExpired() public { + vm.warp(TIME + unbondingPeriod + 1); + + bool authorized = authorizer.authorize(user, address(token), 100); + + assertTrue(authorized); + } + + function test_authorize_revert_whenUnbondingPeriodNotExpired() public { + vm.warp(TIME + unbondingPeriod - 1 hours); + + vm.expectRevert( + abi.encodeWithSelector( + UnbondingPeriodAuthorizer.UnbondingPeriodNotExpired.selector, + uint64(TIME), + uint64(TIME + unbondingPeriod - 1 hours), + uint64(unbondingPeriod) + ) + ); + + authorizer.authorize(user, address(token), 100); + } + + function test_authorize_revert_whenNoRequestExists() public { + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingNotRequested.selector, user, address(0)) + ); + + authorizer.authorize(user, address(0), 100); + } +} + +contract UnbondingPeriodAuthorizerTest_completeUnbondingRequest is UnbondingPeriodAuthorizerTestBase { + uint64 unbondingPeriod = 7 days; + + function setUp() public override { + super.setUp(); + + vm.prank(user); + authorizer.requestUnbonding(address(token), unbondingPeriod); + } + + function test_completeUnbondingRequest_deletesRequest() public { + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token)), "Request should exist before removal"); + + vm.warp(TIME + unbondingPeriod + 1); + + vm.prank(user); + authorizer.completeUnbondingRequest(address(token)); + + assertFalse( + authorizer.hasActiveUnbondingRequest(user, address(token)), "Request should be deleted after removal" + ); + } + + function test_completeUnbondingRequest_emitsEvent() public { + vm.warp(TIME + unbondingPeriod + 1); + + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingCompleted(user, address(token)); + + vm.prank(user); + authorizer.completeUnbondingRequest(address(token)); + } + + function test_completeUnbondingRequest_revert_whenUnbondingPeriodNotExpired() public { + vm.expectRevert( + abi.encodeWithSelector( + UnbondingPeriodAuthorizer.UnbondingPeriodNotExpired.selector, + uint64(TIME), + uint64(block.timestamp), + uint64(unbondingPeriod) + ) + ); + vm.prank(user); + authorizer.completeUnbondingRequest(address(token)); + } + + function test_completeUnbondingRequest_revert_ifNoRequestExists() public { + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingNotRequested.selector, user, address(0)) + ); + vm.prank(user); + authorizer.completeUnbondingRequest(address(0)); + } +} + +contract UnbondingPeriodAuthorizerTest_completeUnbondingRequests is UnbondingPeriodAuthorizerTestBase { + TestERC20 token2; + TestERC20 token3; + uint64 unbondingPeriod = 7 days; + + function setUp() public override { + super.setUp(); + + token2 = new TestERC20("Test2", "TST2", 18, type(uint256).max); + token3 = new TestERC20("Test3", "TST3", 18, type(uint256).max); + + vm.startPrank(user); + authorizer.requestUnbonding(address(token), unbondingPeriod); + authorizer.requestUnbonding(address(token2), unbondingPeriod); + authorizer.requestUnbonding(address(token3), unbondingPeriod); + vm.stopPrank(); + } + + function test_completeUnbondingRequests_deletesMultipleRequests() public { + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token)), "Request for token1 should exist"); + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token2)), "Request for token2 should exist"); + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token3)), "Request for token3 should exist"); + + address[] memory tokens = new address[](3); + tokens[0] = address(token); + tokens[1] = address(token2); + tokens[2] = address(token3); + + vm.warp(TIME + unbondingPeriod + 1); + + vm.prank(user); + authorizer.completeUnbondingRequests(tokens); + + assertFalse(authorizer.hasActiveUnbondingRequest(user, address(token)), "Request for token1 should be deleted"); + assertFalse(authorizer.hasActiveUnbondingRequest(user, address(token2)), "Request for token2 should be deleted"); + assertFalse(authorizer.hasActiveUnbondingRequest(user, address(token3)), "Request for token3 should be deleted"); + } + + function test_completeUnbondingRequests_emitsEventsForEachToken() public { + address[] memory tokens = new address[](2); + tokens[0] = address(token); + tokens[1] = address(token2); + + vm.warp(TIME + unbondingPeriod + 1); + + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingCompleted(user, address(token)); + + vm.expectEmit(true, true, true, true); + emit UnbondingPeriodAuthorizer.UnbondingCompleted(user, address(token2)); + + vm.prank(user); + authorizer.completeUnbondingRequests(tokens); + } + + function test_completeUnbondingRequests_worksWithEmptyArray() public { + address[] memory tokens = new address[](0); + + vm.warp(TIME + unbondingPeriod + 1); + + // Should not revert with empty array + vm.prank(user); + authorizer.completeUnbondingRequests(tokens); + + // Verify no requests were deleted + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token)), "Request for token1 should still exist"); + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token2)), "Request for token2 should still exist"); + assertTrue(authorizer.hasActiveUnbondingRequest(user, address(token3)), "Request for token3 should still exist"); + } + + function test_completeUnbondingRequests_revert_ifAnyTokenHasNoRequest() public { + address[] memory tokens = new address[](2); + tokens[0] = address(token); + tokens[1] = address(0); // No request for this address + + vm.warp(TIME + unbondingPeriod + 1); + + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingNotRequested.selector, user, address(0)) + ); + vm.prank(user); + authorizer.completeUnbondingRequests(tokens); + } + + function test_completeUnbondingRequests_revert_ifTokenRequestedTwice() public { + address[] memory tokens = new address[](3); + tokens[0] = address(token); + tokens[1] = address(token2); + tokens[2] = address(token); // Duplicate + + vm.warp(TIME + unbondingPeriod + 1); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingNotRequested.selector, user, address(token)) + ); + authorizer.completeUnbondingRequests(tokens); + } +} diff --git a/test/vault/UnbondingPeriodAuthorizerTest_integration.t.sol b/test/vault/UnbondingPeriodAuthorizerTest_integration.t.sol new file mode 100644 index 0000000..73c41f0 --- /dev/null +++ b/test/vault/UnbondingPeriodAuthorizerTest_integration.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, Vm} from "forge-std/Test.sol"; + +import {LiteVault} from "../../src/vault/LiteVault.sol"; +import {UnbondingPeriodAuthorizer} from "../../src/vault/UnbondingPeriodAuthorizer.sol"; +import {TestERC20} from "../TestERC20.sol"; +import {IAuthorize} from "../../src/interfaces/IAuthorize.sol"; + +uint256 constant TIME = 1716051867; + +contract UnbondingPeriodAuthorizerTest_integration is Test { + UnbondingPeriodAuthorizer authorizer; + LiteVault vault; + TestERC20 token; + + address deployer = vm.createWallet("deployer").addr; + address owner = vm.createWallet("owner").addr; + address user = vm.createWallet("user").addr; + + uint64 constant UNBONDING_PERIOD = 7 days; + uint256 constant DEPOSIT_AMOUNT = 1000e18; + + function setUp() public { + uint64[] memory supportedPeriods = new uint64[](1); + supportedPeriods[0] = UNBONDING_PERIOD; + + vm.prank(deployer); + authorizer = new UnbondingPeriodAuthorizer(owner, supportedPeriods); + + vm.prank(deployer); + vault = new LiteVault(owner, authorizer); + + token = new TestERC20("Test", "TST", 18, type(uint256).max); + + token.mint(user, DEPOSIT_AMOUNT); + + vm.warp(TIME); + } + + function test_depositAndWithdrawFlow() public { + vm.startPrank(user); + token.approve(address(vault), DEPOSIT_AMOUNT); + vault.deposit(address(token), DEPOSIT_AMOUNT); + vm.stopPrank(); + + assertEq(token.balanceOf(user), 0, "User balance should be 0 after deposit"); + assertEq(token.balanceOf(address(vault)), DEPOSIT_AMOUNT, "Vault balance should equal deposit amount"); + assertEq( + vault.balanceOf(user, address(token)), DEPOSIT_AMOUNT, "User balance in vault should equal deposit amount" + ); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingNotRequested.selector, user, address(token)) + ); + vault.withdraw(address(token), DEPOSIT_AMOUNT); + + vm.prank(user); + authorizer.requestUnbonding(address(token), UNBONDING_PERIOD); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + UnbondingPeriodAuthorizer.UnbondingPeriodNotExpired.selector, + uint64(TIME), + uint64(TIME), + UNBONDING_PERIOD + ) + ); + vault.withdraw(address(token), DEPOSIT_AMOUNT); + + vm.warp(TIME + UNBONDING_PERIOD - 1); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + UnbondingPeriodAuthorizer.UnbondingPeriodNotExpired.selector, + uint64(TIME), + uint64(TIME + UNBONDING_PERIOD - 1), + UNBONDING_PERIOD + ) + ); + vault.withdraw(address(token), DEPOSIT_AMOUNT); + + vm.warp(TIME + UNBONDING_PERIOD + 1); + + vm.prank(user); + authorizer.completeUnbondingRequest(address(token)); + + assertFalse( + authorizer.hasActiveUnbondingRequest(user, address(token)), + "Unbonding request should be deleted after completion" + ); + + // Now the withdrawal should fail as there's no active request to authorize + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(UnbondingPeriodAuthorizer.UnbondingNotRequested.selector, user, address(token)) + ); + vault.withdraw(address(token), DEPOSIT_AMOUNT); + + vm.prank(user); + authorizer.requestUnbonding(address(token), UNBONDING_PERIOD); + + vm.warp(TIME + UNBONDING_PERIOD * 2 + 1); + + // Now withdrawal should succeed without explicitly completing the request first + // The vault will call authorizer.authorize() which checks the unbonding period has passed + vm.prank(user); + vault.withdraw(address(token), DEPOSIT_AMOUNT); + + assertEq(token.balanceOf(user), DEPOSIT_AMOUNT, "User balance should equal deposit amount after withdrawal"); + assertEq(token.balanceOf(address(vault)), 0, "Vault balance should be 0 after withdrawal"); + assertEq(vault.balanceOf(user, address(token)), 0, "User balance in vault should be 0 after withdrawal"); + + // Verify the request still exists after withdrawal + // LiteVault only calls authorize() which doesn't delete the request + assertTrue( + authorizer.hasActiveUnbondingRequest(user, address(token)), + "Unbonding request should still exist after withdrawal" + ); + } +}