Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"makefile.configureOnOpen": false,
"editor.formatOnSave": true,
"[solidity]": {
"editor.formatOnSave": true
},
"solidity.formatter": "forge"
}
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/forge-std
2 changes: 2 additions & 0 deletions src/interfaces/IAuthorize.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/vault/LiteVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand Down
253 changes: 253 additions & 0 deletions src/vault/UnbondingPeriodAuthorizer.sol
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines +6 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imports are not sorted, but should be (according to our Solidity Style Guide, sections 8.B and 8.C)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! Thanks


/**
* @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) {
Comment on lines +159 to +163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When implementing an interface function, the style guide recommends not using the override keyword.

Style Guide, section 2.A: "Do not use override keyword when implementing an interface function."

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, thanks

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);
}
Comment on lines +222 to +224
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the Operational Advice section (3.A.2: "Avoid using msg.sender for permissionless functions.") this function should take an explicit address account parameter to improve interoperability.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is not permissionless as a user should be able to complete only their own unbonding requests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I will modify the clarify the formulation of "permissionless functions" in Operational Advice.


/**
* @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);
}
}
Loading
Loading