From 2433916233190302b47caebe702cf0205337d85a Mon Sep 17 00:00:00 2001 From: Flocqst Date: Wed, 19 Jun 2024 17:54:15 +0200 Subject: [PATCH 01/20] forge install: solady v0.0.208 --- .gitmodules | 3 +++ lib/solady | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/solady diff --git a/.gitmodules b/.gitmodules index 690924b..091175b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..678c916 --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit 678c9163550810b08f0ffb09624c9f7532392303 From c4af7194467a1e66faa29736d9a31150876c7777 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Wed, 19 Jun 2024 18:16:34 +0200 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=91=B7=20convertToShares=20+=20deci?= =?UTF-8?q?malsOffset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KSXVault.sol | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/KSXVault.sol b/src/KSXVault.sol index 4993b24..b308a34 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -4,13 +4,31 @@ pragma solidity 0.8.25; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// @title Kwenta Example Contract /// @notice KSX ERC4626 Vault /// @author Flocqst (florian@kwenta.io) contract KSXVault is ERC4626 { - constructor(address _token) + + /// @notice Decimal offset used for calculating the conversion rate between KWENTA and KSX. + /// @dev Set to 3 to ensure the initial fixed ratio of 1,000 KSX per KWENTA + uint256 public immutable decimalsOffset; + + constructor(address _token, uint256 _decimalsOffset) ERC4626(IERC20(_token)) ERC20("KSX Vault", "KSX") - {} + { + decimalsOffset = _decimalsOffset; + } + + + function convertToShares(uint256 assets) public view virtual override returns (uint256 shares) { + uint256 o = decimalsOffset; + if (o == 0) { + return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 1, totalAssets() + 1); + } + return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 10 ** o, totalAssets() + 1); + } + } From 1baac391e144793ee7c93fb92879146431acf8e9 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Wed, 26 Jun 2024 14:10:24 +0200 Subject: [PATCH 03/20] chore: add openzeppelin-contracts-upgradeable library as submodule --- .gitmodules | 3 +++ lib/openzeppelin-contracts-upgradeable | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/openzeppelin-contracts-upgradeable diff --git a/.gitmodules b/.gitmodules index 091175b..2dffef7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "lib/solady"] path = lib/solady url = https://github.com/vectorized/solady +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..723f8ca --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 From f8ab80b0c55e063e1b7b04652976e4fb8ccb8b6a Mon Sep 17 00:00:00 2001 From: Flocqst Date: Thu, 27 Jun 2024 17:23:36 +0200 Subject: [PATCH 04/20] =?UTF-8?q?=E2=9A=99=20Update=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- foundry.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/foundry.toml b/foundry.toml index 7670d09..8a7c62a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,6 +10,11 @@ optimizer_runs = 1_000_000 [fmt] line_length = 80 number_underscore = "thousands" +multiline_func_header = "all" +sort_imports = true +contract_new_lines = true +override_spacing = false +wrap_comments = true [rpc_endpoints] mainnet = "${MAINNET_RPC_URL}" From ba43d77a23914a3db07b6ffd8a9dd34216857097 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Thu, 27 Jun 2024 17:24:31 +0200 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=9A=80=20Deploy=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/Deploy.s.sol | 87 +++++-------------- .../utils/parameters/BaseGoerliParameters.sol | 4 - script/utils/parameters/BaseParameters.sol | 4 - .../parameters/OptimismGoerliParameters.sol | 12 ++- .../utils/parameters/OptimismParameters.sol | 12 ++- 5 files changed, 45 insertions(+), 74 deletions(-) delete mode 100644 script/utils/parameters/BaseGoerliParameters.sol delete mode 100644 script/utils/parameters/BaseParameters.sol diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index f8e746c..d7415e1 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,81 +1,40 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.25; -// TODO: adapt deploy script to deploy the KSXVault contract -/* -import {BaseGoerliParameters} from - "script/utils/parameters/BaseGoerliParameters.sol"; -import {BaseParameters} from "script/utils/parameters/BaseParameters.sol"; +// proxy +import {ERC1967Proxy as Proxy} from + "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +// contracts +import {KSXVault} from "src/KSXVault.sol"; + +// parameters import {OptimismGoerliParameters} from "script/utils/parameters/OptimismGoerliParameters.sol"; import {OptimismParameters} from "script/utils/parameters/OptimismParameters.sol"; + +// forge utils import {Script} from "lib/forge-std/src/Script.sol"; -import {Counter} from "src/Counter.sol"; -/// @title Kwenta deployment script +/// @title Kwenta KSX deployment script /// @author Flocqst (florian@kwenta.io) contract Setup is Script { - function deploySystem() public returns (address) { - Counter counter = new Counter(); - return address(counter); - } -} - -/// @dev steps to deploy and verify on Base: -/// (1) load the variables in the .env file via `source .env` -/// (2) run `forge script script/Deploy.s.sol:DeployBase --rpc-url $BASE_RPC_URL --etherscan-api-key $BASESCAN_API_KEY --broadcast --verify -vvvv` -contract DeployBase is Setup, BaseParameters { - function run() public { - uint256 privateKey = vm.envUint("PRIVATE_KEY"); - vm.startBroadcast(privateKey); - - Setup.deploySystem(); - - vm.stopBroadcast(); - } -} -/// @dev steps to deploy and verify on Base Goerli: -/// (1) load the variables in the .env file via `source .env` -/// (2) run `forge script script/Deploy.s.sol:DeployBaseGoerli --rpc-url $BASE_GOERLI_RPC_URL --etherscan-api-key $BASESCAN_API_KEY --broadcast --verify -vvvv` -contract DeployBaseGoerli is Setup, BaseGoerliParameters { - function run() public { - uint256 privateKey = vm.envUint("PRIVATE_KEY"); - vm.startBroadcast(privateKey); + function deploySystem( + address token, + uint8 decimalOffset + ) + public + returns (KSXVault ksxVault) + { + ksxVault = new KSXVault(token, decimalOffset); - Setup.deploySystem(); + // deploy ERC1967 proxy and set implementation to ksxVault + Proxy proxy = new Proxy(address(ksxVault), ""); - vm.stopBroadcast(); + // "wrap" proxy in IKSXVault interface + ksxVault = KSXVault(address(proxy)); } -} - -/// @dev steps to deploy and verify on Optimism: -/// (1) load the variables in the .env file via `source .env` -/// (2) run `forge script script/Deploy.s.sol:DeployOptimism --rpc-url $OPTIMISM_RPC_URL --etherscan-api-key $OPTIMISM_ETHERSCAN_API_KEY --broadcast --verify -vvvv` -contract DeployOptimism is Setup, OptimismParameters { - function run() public { - uint256 privateKey = vm.envUint("PRIVATE_KEY"); - vm.startBroadcast(privateKey); - - Setup.deploySystem(); - - vm.stopBroadcast(); - } -} - -/// @dev steps to deploy and verify on Optimism Goerli: -/// (1) load the variables in the .env file via `source .env` -/// (2) run `forge script script/Deploy.s.sol:DeployOptimismGoerli --rpc-url $OPTIMISM_GOERLI_RPC_URL --etherscan-api-key $OPTIMISM_ETHERSCAN_API_KEY --broadcast --verify -vvvv` - -contract DeployOptimismGoerli is Setup, OptimismGoerliParameters { - function run() public { - uint256 privateKey = vm.envUint("PRIVATE_KEY"); - vm.startBroadcast(privateKey); - Setup.deploySystem(); - - vm.stopBroadcast(); - } } -*/ diff --git a/script/utils/parameters/BaseGoerliParameters.sol b/script/utils/parameters/BaseGoerliParameters.sol deleted file mode 100644 index fb96ba3..0000000 --- a/script/utils/parameters/BaseGoerliParameters.sol +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.25; - -contract BaseGoerliParameters {} diff --git a/script/utils/parameters/BaseParameters.sol b/script/utils/parameters/BaseParameters.sol deleted file mode 100644 index 2e1f179..0000000 --- a/script/utils/parameters/BaseParameters.sol +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.25; - -contract BaseParameters {} diff --git a/script/utils/parameters/OptimismGoerliParameters.sol b/script/utils/parameters/OptimismGoerliParameters.sol index 04cecd1..02e381d 100644 --- a/script/utils/parameters/OptimismGoerliParameters.sol +++ b/script/utils/parameters/OptimismGoerliParameters.sol @@ -1,4 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.25; -contract OptimismGoerliParameters {} +contract OptimismGoerliParameters { + + /// @dev this is an EOA used on testnet only + address public constant PDAO = 0x1b4fCFE451A15218aEeC811B508B4aa3f2A35904; + + // https://developers.circle.com/stablecoins/docs/usdc-on-test-networks#usdc-on-op-goerli + address public constant USDC = 0xe05606174bac4A6364B31bd0eCA4bf4dD368f8C6; + + address public constant KWENTA = 0x920Cf626a271321C151D027030D5d08aF699456b; + +} diff --git a/script/utils/parameters/OptimismParameters.sol b/script/utils/parameters/OptimismParameters.sol index 7f54df5..341440e 100644 --- a/script/utils/parameters/OptimismParameters.sol +++ b/script/utils/parameters/OptimismParameters.sol @@ -1,4 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.25; -contract OptimismParameters {} +contract OptimismParameters { + + address public constant PDAO = 0xe826d43961a87fBE71C91d9B73F7ef9b16721C07; + + // https://optimistic.etherscan.io/token/0x0b2c639c533813f4aa9d7837caf62653d097ff85 + address public constant USDC = 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85; + + // https://optimistic.etherscan.io/token/0x920cf626a271321c151d027030d5d08af699456b + address public constant KWENTA = 0x920Cf626a271321C151D027030D5d08aF699456b; + +} From d43cc43168c7f8f1bbfe2d513e325cce4e3ee4e4 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Thu, 27 Jun 2024 17:25:29 +0200 Subject: [PATCH 06/20] =?UTF-8?q?=F0=9F=91=B7=20decimalOffset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KSXVault.sol | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/KSXVault.sol b/src/KSXVault.sol index b308a34..6337510 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -1,34 +1,47 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.25; +import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; -import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; -/// @title Kwenta Example Contract +/// @title KSXVault Contract /// @notice KSX ERC4626 Vault /// @author Flocqst (florian@kwenta.io) contract KSXVault is ERC4626 { - /// @notice Decimal offset used for calculating the conversion rate between KWENTA and KSX. + /*////////////////////////////////////////////////////////////// + IMMUTABLES + //////////////////////////////////////////////////////////////*/ + + /// @notice Decimal offset used for calculating the conversion rate between + /// KWENTA and KSX. /// @dev Set to 3 to ensure the initial fixed ratio of 1,000 KSX per KWENTA - uint256 public immutable decimalsOffset; + /// further protect against inflation attacks + /// (https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack) + uint8 public immutable offset; - constructor(address _token, uint256 _decimalsOffset) + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /// @notice Constructs the KSXVault contract + /// @param _token Kwenta token address + /// @param _offset offset in the decimal representation between the + /// underlying asset's decimals and the vault decimals + constructor( + address _token, + uint8 _offset + ) ERC4626(IERC20(_token)) ERC20("KSX Vault", "KSX") { - decimalsOffset = _decimalsOffset; + offset = _offset; } - - function convertToShares(uint256 assets) public view virtual override returns (uint256 shares) { - uint256 o = decimalsOffset; - if (o == 0) { - return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 1, totalAssets() + 1); - } - return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 10 ** o, totalAssets() + 1); + function _decimalsOffset() internal view virtual override returns (uint8) { + return offset; } } From ffbec65b17550702cfe799c183fc1ed1634d40b7 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Thu, 27 Jun 2024 17:25:59 +0200 Subject: [PATCH 07/20] =?UTF-8?q?=E2=9C=85=20Test=20shares=20mechanism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/KSXVault.t.sol | 93 +++++++++++++++++++++++++++++++++++++--- test/utils/Bootstrap.sol | 71 +++++++++++++----------------- test/utils/Constants.sol | 18 ++++++++ 3 files changed, 135 insertions(+), 47 deletions(-) create mode 100644 test/utils/Constants.sol diff --git a/test/KSXVault.t.sol b/test/KSXVault.t.sol index a980305..567937f 100644 --- a/test/KSXVault.t.sol +++ b/test/KSXVault.t.sol @@ -1,20 +1,103 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.25; -import {Test} from "forge-std/Test.sol"; -import {KSXVault} from "../src/KSXVault.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Test} from "forge-std/Test.sol"; +import {Bootstrap, KSXVault} from "test/utils/Bootstrap.sol"; + +contract KSXVaultTest is Bootstrap { + + MockERC20 depositToken; + + function setUp() public { + depositToken = new MockERC20("Deposit Token", "DT"); + initializeLocal(address(depositToken), DECIMAL_OFFSET); + + depositToken.mint(alice, 10 ether); + depositToken.mint(bob, 10 ether); + } + + // Asserts decimals offset is correctly set to 3 + function test_vault_decimalsOffset() public { + assertEq(ksxVault.offset(), 3); + } + + // Asserts correct deposit at 1000 shares ratio + // Converts asset values to shares and deposits assets into the vault + function test_vault_deposit() public { + uint256 amount = 1 ether; + vm.startPrank(alice); + depositToken.approve(address(ksxVault), amount); + ksxVault.deposit(1 ether, alice); + assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); + assertEq(depositToken.balanceOf(address(ksxVault)), amount); + vm.stopPrank(); + } + + // Asserts correct mint at 1000 shares ratio + // Mints a specified number of shares and requires the equivalent asset + // value to be deposited + function test_vault_mint() public { + uint256 amount = 1 ether; + vm.startPrank(alice); + depositToken.approve(address(ksxVault), amount); + ksxVault.mint(1 ether, alice); + assertEq(ksxVault.balanceOf(alice), amount); + assertEq( + depositToken.balanceOf(address(ksxVault)), + amount / (10 ** ksxVault.offset()) + ); + vm.stopPrank(); + } + + // Withdraws a specified amount of assets from the vault by burning the + // equivalent shares + function test_withdraw() public { + uint256 amount = 1 ether; + vm.startPrank(alice); + depositToken.approve(address(ksxVault), amount); + ksxVault.deposit(amount, alice); + assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); + assertEq(depositToken.balanceOf(address(ksxVault)), amount); + + ksxVault.withdraw(amount, alice, alice); + assertEq(ksxVault.balanceOf(alice), 0); + assertEq(depositToken.balanceOf(address(ksxVault)), 0); + assertEq(depositToken.balanceOf(alice), 10 ether); + vm.stopPrank(); + } + + function test_redeem() public { + uint256 amount = 1 ether; + vm.startPrank(alice); + depositToken.approve(address(ksxVault), amount); + ksxVault.mint(1 ether, alice); + assertEq(ksxVault.balanceOf(alice), amount); + assertEq( + depositToken.balanceOf(address(ksxVault)), + amount / (10 ** ksxVault.offset()) + ); + + ksxVault.redeem(amount, alice, alice); + assertEq(ksxVault.balanceOf(alice), 0); + assertEq(depositToken.balanceOf(address(ksxVault)), 0); + assertEq(depositToken.balanceOf(alice), 10 ether); + vm.stopPrank(); + } -contract KSXVaultTest is Test { - function setUp() public {} } contract MockERC20 is ERC20 { - constructor(string memory name_, string memory symbol_) + + constructor( + string memory name_, + string memory symbol_ + ) ERC20(name_, symbol_) {} function mint(address to, uint256 amount) external { _mint(to, amount); } + } diff --git a/test/utils/Bootstrap.sol b/test/utils/Bootstrap.sol index 9d264d1..fdf21fe 100644 --- a/test/utils/Bootstrap.sol +++ b/test/utils/Bootstrap.sol @@ -1,69 +1,56 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.25; -// TODO: adapt deploy script to deploy the KSXVault contract -/* +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Test} from "lib/forge-std/src/Test.sol"; import {console2} from "lib/forge-std/src/console2.sol"; import { - Counter, OptimismGoerliParameters, OptimismParameters, Setup } from "script/Deploy.s.sol"; -import {Test} from "lib/forge-std/src/Test.sol"; +import {KSXVault} from "src/KSXVault.sol"; +import {Constants} from "test/utils/Constants.sol"; -contract Bootstrap is Test { - using console2 for *; +contract Bootstrap is Test, Constants { - Counter internal counter; + using console2 for *; - function initializeLocal() internal { - BootstrapLocal bootstrap = new BootstrapLocal(); - (address counterAddress) = bootstrap.init(); + // decimal offset + uint256 public decimalsOffset; - counter = Counter(counterAddress); - } + // deployed contracts + KSXVault internal ksxVault; - function initializeOptimismGoerli() internal { - BootstrapOptimismGoerli bootstrap = new BootstrapOptimismGoerli(); - (address counterAddress) = bootstrap.init(); + IERC20 public TOKEN; - counter = Counter(counterAddress); - } + // testing addresses + address constant alice = address(0xAAAA); + address constant bob = address(0xBBBB); - function initializeOptimism() internal { - BootstrapOptimismGoerli bootstrap = new BootstrapOptimismGoerli(); - (address counterAddress) = bootstrap.init(); + function initializeLocal(address _token, uint8 _decimalsOffset) internal { + BootstrapLocal bootstrap = new BootstrapLocal(); + (address ksxVaultAddress) = bootstrap.init(_token, _decimalsOffset); - counter = Counter(counterAddress); + decimalsOffset = _decimalsOffset; + TOKEN = IERC20(_token); + ksxVault = KSXVault(ksxVaultAddress); } - /// @dev add other networks here as needed (ex: Base, BaseGoerli) } contract BootstrapLocal is Setup { - function init() public returns (address) { - address counterAddress = Setup.deploySystem(); - return counterAddress; - } -} + function init( + address _token, + uint8 _decimalsOffset + ) + public + returns (address) + { + (KSXVault ksxvault) = Setup.deploySystem(_token, _decimalsOffset); -contract BootstrapOptimism is Setup, OptimismParameters { - function init() public returns (address) { - address counterAddress = Setup.deploySystem(); - - return counterAddress; + return (address(ksxvault)); } -} -contract BootstrapOptimismGoerli is Setup, OptimismGoerliParameters { - function init() public returns (address) { - address counterAddress = Setup.deploySystem(); - - return counterAddress; - } } - -// add other networks here as needed (ex: Base, BaseGoerli) -*/ diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol new file mode 100644 index 0000000..491b149 --- /dev/null +++ b/test/utils/Constants.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.25; + +/// @title Contract for defining constants used in testing +contract Constants { + + uint256 public constant BASE_BLOCK_NUMBER = 8_225_680; + + address internal constant ACTOR = address(0xa1); + + address internal constant BAD_ACTOR = address(0xa2); + + address public constant PDAOADDR = + 0xe826d43961a87fBE71C91d9B73F7ef9b16721C07; + + uint8 public constant DECIMAL_OFFSET = 3; + +} From 84e817d4092484837d84ebbbf1fa27c79de177b3 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Fri, 28 Jun 2024 16:37:18 +0200 Subject: [PATCH 08/20] =?UTF-8?q?=F0=9F=91=B7=20stake=20and=20compound=20f?= =?UTF-8?q?unctions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KSXVault.sol | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/KSXVault.sol b/src/KSXVault.sol index 6337510..d204ee1 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -44,4 +44,27 @@ contract KSXVault is ERC4626 { return offset; } + /*////////////////////////////////////////////////////////////// + STAKING MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + /// @notice Claim KWENTA rewards + function _claimKWENTARewards() internal returns (uint256) { + // Implement the logic to claim KWENTA rewards + } + + /// @notice Stake KWENTA tokens + function _stakeKWENTA(uint256 kwentaAmount) internal { + // Implement the logic to stake KWENTA tokens back into the vault + } + + /// @notice Modifier to compound unstaked KWENTA tokens before executing the + /// function + modifier compoundUnstakedKWENTA() { + // StakingV2 harvest rewards + // TokenDistributor claim USDC + // Swap USDC for KWENTA and stake + _; + } + } From 04bc852f9776d88d73175afc299019590b72b1d2 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 30 Jul 2024 14:48:56 +0200 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=91=B7=20Add=20IStakingRewardsV2=20?= =?UTF-8?q?interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/interfaces/IStakingRewardsV2.sol | 303 +++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 src/interfaces/IStakingRewardsV2.sol diff --git a/src/interfaces/IStakingRewardsV2.sol b/src/interfaces/IStakingRewardsV2.sol new file mode 100644 index 0000000..7a94cf6 --- /dev/null +++ b/src/interfaces/IStakingRewardsV2.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +interface IStakingRewardsV2 { + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + + /// @notice A checkpoint for tracking values at a given timestamp + struct Checkpoint { + // The timestamp when the value was generated + uint64 ts; + // The block number when the value was generated + uint64 blk; + // The value of the checkpoint + /// @dev will not overflow unless it value reaches 340 quintillion + /// This number should be impossible to reach with the total supply of $KWENTA + uint128 value; + } + + /*/////////////////////////////////////////////////////////////// + INITIALIZER + ///////////////////////////////////////////////////////////////*/ + + /// @notice Initializes the contract + /// @param _owner: owner of this contract + /// @dev this function should be called via proxy, not via direct contract interaction + function initialize(address _owner) external; + + /*////////////////////////////////////////////////////////////// + Views + //////////////////////////////////////////////////////////////*/ + // token state + + /// @dev returns staked tokens which will likely not be equal to total tokens + /// in the contract since reward and staking tokens are the same + /// @return total amount of tokens that are being staked + function totalSupply() external view returns (uint256); + + // staking state + + /// @notice Returns the total number of staked tokens for a user + /// the sum of all escrowed and non-escrowed tokens + /// @param _account: address of potential staker + /// @return amount of tokens staked by account + function balanceOf(address _account) external view returns (uint256); + + /// @notice Getter function for number of staked escrow tokens + /// @param _account address to check the escrowed tokens staked + /// @return amount of escrowed tokens staked + function escrowedBalanceOf(address _account) external view returns (uint256); + + /// @notice Getter function for number of staked non-escrow tokens + /// @param _account address to check the non-escrowed tokens staked + /// @return amount of non-escrowed tokens staked + function nonEscrowedBalanceOf(address _account) external view returns (uint256); + + /// @notice Getter function for the total number of escrowed tokens that are not not staked + /// @param _account: address to check + /// @return amount of tokens escrowed but not staked + function unstakedEscrowedBalanceOf(address _account) external view returns (uint256); + + // rewards + + /// @notice calculate the total rewards for one duration based on the current rate + /// @return rewards for the duration specified by rewardsDuration + function getRewardForDuration() external view returns (uint256); + + /// @notice calculate running sum of reward per total tokens staked + /// at this specific time + /// @return running sum of reward per total tokens staked + function rewardPerToken() external view returns (uint256); + + /// @notice get the last time a reward is applicable for a given user + /// @return timestamp of the last time rewards are applicable + function lastTimeRewardApplicable() external view returns (uint256); + + /// @notice determine how much reward token an account has earned thus far + /// @param _account: address of account earned amount is being calculated for + function earned(address _account) external view returns (uint256); + + // checkpointing + + /// @notice get the number of balances checkpoints for an account + /// @param _account: address of account to check + /// @return number of balances checkpoints + function balancesCheckpointsLength(address _account) external view returns (uint256); + + /// @notice get the number of escrowed balance checkpoints for an account + /// @param _account: address of account to check + /// @return number of escrowed balance checkpoints + function escrowedBalancesCheckpointsLength(address _account) external view returns (uint256); + + /// @notice get the number of total supply checkpoints + /// @return number of total supply checkpoints + function totalSupplyCheckpointsLength() external view returns (uint256); + + /// @notice get a users balance at a given timestamp + /// @param _account: address of account to check + /// @param _timestamp: timestamp to check + /// @return balance at given timestamp + /// @dev if called with a timestamp that equals the current block timestamp, then the function might return inconsistent + /// values as further transactions changing the balances can still occur within the same block. + function balanceAtTime(address _account, uint256 _timestamp) external view returns (uint256); + + /// @notice get a users escrowed balance at a given timestamp + /// @param _account: address of account to check + /// @param _timestamp: timestamp to check + /// @return escrowed balance at given timestamp + /// @dev if called with a timestamp that equals the current block timestamp, then the function might return inconsistent + /// values as further transactions changing the balances can still occur within the same block. + function escrowedBalanceAtTime(address _account, uint256 _timestamp) + external + view + returns (uint256); + + /// @notice get the total supply at a given timestamp + /// @param _timestamp: timestamp to check + /// @return total supply at given timestamp + /// @dev if called with a timestamp that equals the current block timestamp, then the function might return inconsistent + /// values as further transactions changing the balances can still occur within the same block. + function totalSupplyAtTime(uint256 _timestamp) external view returns (uint256); + + /*////////////////////////////////////////////////////////////// + Mutative + //////////////////////////////////////////////////////////////*/ + // Staking/Unstaking + + /// @notice stake token + /// @param _amount: amount to stake + /// @dev updateReward() called prior to function logic + function stake(uint256 _amount) external; + + /// @notice unstake token + /// @param _amount: amount to unstake + /// @dev updateReward() called prior to function logic + function unstake(uint256 _amount) external; + + /// @notice stake escrowed token + /// @param _amount: amount to stake + /// @dev updateReward() called prior to function logic + function stakeEscrow(uint256 _amount) external; + + /// @notice unstake escrowed token + /// @param _amount: amount to unstake + /// @dev updateReward() called prior to function logic + function unstakeEscrow(uint256 _amount) external; + + /// @notice unstake escrowed token on behalf of another account + /// @param _account: address of account to unstake from + /// @param _amount: amount to unstake + /// @dev this function is used to allow tokens to be vested at any time by RewardEscrowV2 + function unstakeEscrowAdmin(address _account, uint256 _amount) external; + + /// @notice unstake all available staked non-escrowed tokens and + /// claim any rewards + function exit() external; + + // claim rewards + + /// @notice caller claims any rewards generated from staking + /// @dev rewards are escrowed in RewardEscrow + /// @dev updateReward() called prior to function logic + function getReward() external; + + /// @notice claim rewards for an account and stake them + function compound() external; + + // delegation + + /// @notice approve an operator to collect rewards and stake escrow on behalf of the sender + /// @param operator: address of operator to approve + /// @param approved: whether or not to approve the operator + function approveOperator(address operator, bool approved) external; + + /// @notice stake escrowed token on behalf of another account + /// @param _account: address of account to stake on behalf of + /// @param _amount: amount to stake + function stakeEscrowOnBehalf(address _account, uint256 _amount) external; + + /// @notice caller claims any rewards generated from staking on behalf of another account + /// The rewards will be escrowed in RewardEscrow with the account as the beneficiary + /// @param _account: address of account to claim rewards for + function getRewardOnBehalf(address _account) external; + + /// @notice claim and stake rewards on behalf of another account + /// @param _account: address of account to claim and stake rewards for + function compoundOnBehalf(address _account) external; + + // settings + + /// @notice configure reward rate + /// @param _reward: amount of token to be distributed over a period + /// @dev updateReward() called prior to function logic (with zero address) + function notifyRewardAmount(uint256 _reward) external; + + /// @notice set rewards duration + /// @param _rewardsDuration: denoted in seconds + function setRewardsDuration(uint256 _rewardsDuration) external; + + // pausable + + /// @dev Triggers stopped state + function pauseStakingRewards() external; + + /// @dev Returns to normal state. + function unpauseStakingRewards() external; + + // misc. + + /// @notice added to support recovering LP Rewards from other systems + /// such as BAL to be distributed to holders + /// @param tokenAddress: address of token to be recovered + /// @param tokenAmount: amount of token to be recovered + function recoverERC20(address tokenAddress, uint256 tokenAmount) external; + + /*/////////////////////////////////////////////////////////////// + EVENTS + ///////////////////////////////////////////////////////////////*/ + + /// @notice update reward rate + /// @param reward: amount to be distributed over applicable rewards duration + event RewardAdded(uint256 reward); + + /// @notice emitted when user stakes tokens + /// @param user: staker address + /// @param amount: amount staked + event Staked(address indexed user, uint256 amount); + + /// @notice emitted when user unstakes tokens + /// @param user: address of user unstaking + /// @param amount: amount unstaked + event Unstaked(address indexed user, uint256 amount); + + /// @notice emitted when escrow staked + /// @param user: owner of escrowed tokens address + /// @param amount: amount staked + event EscrowStaked(address indexed user, uint256 amount); + + /// @notice emitted when staked escrow tokens are unstaked + /// @param user: owner of escrowed tokens address + /// @param amount: amount unstaked + event EscrowUnstaked(address user, uint256 amount); + + /// @notice emitted when user claims rewards + /// @param user: address of user claiming rewards + /// @param reward: amount of reward token claimed + event RewardPaid(address indexed user, uint256 reward); + + /// @notice emitted when rewards duration changes + /// @param newDuration: denoted in seconds + event RewardsDurationUpdated(uint256 newDuration); + + /// @notice emitted when tokens are recovered from this contract + /// @param token: address of token recovered + /// @param amount: amount of token recovered + event Recovered(address token, uint256 amount); + + /// @notice emitted when an operator is approved + /// @param owner: owner of tokens + /// @param operator: address of operator + /// @param approved: whether or not operator is approved + event OperatorApproved(address owner, address operator, bool approved); + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + /// @notice error someone other than reward escrow calls an onlyRewardEscrow function + error OnlyRewardEscrow(); + + /// @notice error someone other than the rewards notifier calls an onlyRewardsNotifier function + error OnlyRewardsNotifier(); + + /// @notice cannot set this value to the zero address + error ZeroAddress(); + + /// @notice error when user tries to stake/unstake 0 tokens + error AmountZero(); + + /// @notice the user does not have enough tokens to unstake that amount + /// @param availableBalance: amount of tokens available to withdraw + error InsufficientBalance(uint256 availableBalance); + + /// @notice error when trying to stakeEscrow more than the unstakedEscrow available + /// @param unstakedEscrow amount of unstaked escrow + error InsufficientUnstakedEscrow(uint256 unstakedEscrow); + + /// @notice previous rewards period must be complete before changing the duration for the new period + error RewardsPeriodNotComplete(); + + /// @notice recovering the staking token is not allowed + error CannotRecoverStakingToken(); + + /// @notice error when trying to set a rewards duration that is too short + error RewardsDurationCannotBeZero(); + + /// @notice the caller is not approved to take this action + error NotApproved(); + + /// @notice attempted to approve self as an operator + error CannotApproveSelf(); +} From 489b236e0ae07b3e690af878e3309e22949262b7 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 30 Jul 2024 14:49:21 +0200 Subject: [PATCH 10/20] =?UTF-8?q?=F0=9F=91=B7=20Add=20ERC4626=20mint=20fun?= =?UTF-8?q?ctions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KSXVault.sol | 66 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/KSXVault.sol b/src/KSXVault.sol index d204ee1..6db30fd 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -5,6 +5,7 @@ import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; +import {IStakingRewardsV2} from "src/interfaces/IStakingRewardsV2.sol"; /// @title KSXVault Contract /// @notice KSX ERC4626 Vault @@ -22,22 +23,28 @@ contract KSXVault is ERC4626 { /// (https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack) uint8 public immutable offset; + /// @notice Synthetix v3 perps market proxy contract + IStakingRewardsV2 internal immutable STAKING_REWARDS; + /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ /// @notice Constructs the KSXVault contract /// @param _token Kwenta token address + /// @param _stakingRewards Kwenta v2 staking rewards contract /// @param _offset offset in the decimal representation between the /// underlying asset's decimals and the vault decimals constructor( address _token, + address _stakingRewards, uint8 _offset ) ERC4626(IERC20(_token)) ERC20("KSX Vault", "KSX") { offset = _offset; + STAKING_REWARDS = IStakingRewardsV2(_stakingRewards); } function _decimalsOffset() internal view virtual override returns (uint8) { @@ -55,7 +62,7 @@ contract KSXVault is ERC4626 { /// @notice Stake KWENTA tokens function _stakeKWENTA(uint256 kwentaAmount) internal { - // Implement the logic to stake KWENTA tokens back into the vault + STAKING_REWARDS.stake(kwentaAmount); } /// @notice Modifier to compound unstaked KWENTA tokens before executing the @@ -67,4 +74,61 @@ contract KSXVault is ERC4626 { _; } + + /** @dev See {IERC4626-deposit}. */ + function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { + uint256 maxAssets = maxDeposit(receiver); + if (assets > maxAssets) { + revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); + } + + uint256 shares = previewDeposit(assets); + _deposit(_msgSender(), receiver, assets, shares); + + return shares; + } + + /** @dev See {IERC4626-mint}. + * + * As opposed to {deposit}, minting is allowed even if the vault is in a state where the price of a share is zero. + * In this case, the shares will be minted without requiring any assets to be deposited. + */ + function mint(uint256 shares, address receiver) public virtual override returns (uint256) { + uint256 maxShares = maxMint(receiver); + if (shares > maxShares) { + revert ERC4626ExceededMaxMint(receiver, shares, maxShares); + } + + uint256 assets = previewMint(shares); + _deposit(_msgSender(), receiver, assets, shares); + + return assets; + } + + /** @dev See {IERC4626-withdraw}. */ + function withdraw(uint256 assets, address receiver, address owner) public virtual override returns (uint256) { + uint256 maxAssets = maxWithdraw(owner); + if (assets > maxAssets) { + revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets); + } + + uint256 shares = previewWithdraw(assets); + _withdraw(_msgSender(), receiver, owner, assets, shares); + + return shares; + } + + /** @dev See {IERC4626-redeem}. */ + function redeem(uint256 shares, address receiver, address owner) public virtual override returns (uint256) { + uint256 maxShares = maxRedeem(owner); + if (shares > maxShares) { + revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); + } + + uint256 assets = previewRedeem(shares); + _withdraw(_msgSender(), receiver, owner, assets, shares); + + return assets; + } + } From 39444a7cec98110ce831737677d813ae299004b2 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 10 Sep 2024 17:02:35 +0200 Subject: [PATCH 11/20] =?UTF-8?q?=F0=9F=91=B7=20Resolve=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitmodules | 4 + lib/token | 1 + src/KSXVault.sol | 130 ++++++------ src/interfaces/IStakingRewardsV2.sol | 303 --------------------------- 4 files changed, 71 insertions(+), 367 deletions(-) create mode 160000 lib/token delete mode 100644 src/interfaces/IStakingRewardsV2.sol diff --git a/.gitmodules b/.gitmodules index 2dffef7..3970c70 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,7 @@ [submodule "lib/openzeppelin-contracts-upgradeable"] path = lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/token"] + path = lib/token + url = git@github.com:Kwenta/token.git + branch = remove-inflation-escrow diff --git a/lib/token b/lib/token new file mode 160000 index 0000000..48a2b16 --- /dev/null +++ b/lib/token @@ -0,0 +1 @@ +Subproject commit 48a2b16469adfb4794c75f5b7820a2d40dd5c29e diff --git a/src/KSXVault.sol b/src/KSXVault.sol index 6db30fd..ff2d4fb 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -4,8 +4,7 @@ pragma solidity 0.8.25; import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; -import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; -import {IStakingRewardsV2} from "src/interfaces/IStakingRewardsV2.sol"; +import {IStakingRewardsV2} from "@token/interfaces/IStakingRewardsV2.sol"; /// @title KSXVault Contract /// @notice KSX ERC4626 Vault @@ -23,9 +22,13 @@ contract KSXVault is ERC4626 { /// (https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack) uint8 public immutable offset; - /// @notice Synthetix v3 perps market proxy contract + /// @notice Kwenta's StakingRewards contract IStakingRewardsV2 internal immutable STAKING_REWARDS; + /// @notice KWENTA TOKEN + /// @dev The underlying asset of this vault + ERC20 private immutable KWENTA; + /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -45,90 +48,89 @@ contract KSXVault is ERC4626 { { offset = _offset; STAKING_REWARDS = IStakingRewardsV2(_stakingRewards); + KWENTA = ERC20(_token); } + /// @notice Returns the decimal offset for the vault + /// @dev This function is used internally by the ERC4626 implementation + /// @return The decimal offset value function _decimalsOffset() internal view virtual override returns (uint8) { return offset; } /*////////////////////////////////////////////////////////////// - STAKING MANAGEMENT + DEPOSIT/MINT FUNCTIONS //////////////////////////////////////////////////////////////*/ - /// @notice Claim KWENTA rewards - function _claimKWENTARewards() internal returns (uint256) { - // Implement the logic to claim KWENTA rewards - } - - /// @notice Stake KWENTA tokens - function _stakeKWENTA(uint256 kwentaAmount) internal { - STAKING_REWARDS.stake(kwentaAmount); - } - - /// @notice Modifier to compound unstaked KWENTA tokens before executing the - /// function - modifier compoundUnstakedKWENTA() { - // StakingV2 harvest rewards - // TokenDistributor claim USDC - // Swap USDC for KWENTA and stake - _; - } - - - /** @dev See {IERC4626-deposit}. */ + /// @notice Deposit assets into the vault + /// @dev Overrides the ERC4626 deposit function to include reward collection and staking + /// @param assets The amount of assets to deposit + /// @param receiver The address to receive the minted shares + /// @return shares The amount of shares minted function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { - uint256 maxAssets = maxDeposit(receiver); - if (assets > maxAssets) { - revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); - } - - uint256 shares = previewDeposit(assets); - _deposit(_msgSender(), receiver, assets, shares); - + uint256 shares = super.deposit(assets, receiver); + _collectAndStakeRewards(); return shares; } - /** @dev See {IERC4626-mint}. - * - * As opposed to {deposit}, minting is allowed even if the vault is in a state where the price of a share is zero. - * In this case, the shares will be minted without requiring any assets to be deposited. - */ + /// @notice Mint shares of the vault + /// @dev Overrides the ERC4626 mint function to include reward collection and staking + /// @param shares The amount of shares to mint + /// @param receiver The address to receive the minted shares + /// @return assets The amount of assets deposited function mint(uint256 shares, address receiver) public virtual override returns (uint256) { - uint256 maxShares = maxMint(receiver); - if (shares > maxShares) { - revert ERC4626ExceededMaxMint(receiver, shares, maxShares); - } - - uint256 assets = previewMint(shares); - _deposit(_msgSender(), receiver, assets, shares); - + uint256 assets = super.mint(shares, receiver); + _collectAndStakeRewards(); return assets; } - /** @dev See {IERC4626-withdraw}. */ - function withdraw(uint256 assets, address receiver, address owner) public virtual override returns (uint256) { - uint256 maxAssets = maxWithdraw(owner); - if (assets > maxAssets) { - revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets); - } - - uint256 shares = previewWithdraw(assets); - _withdraw(_msgSender(), receiver, owner, assets, shares); + /*////////////////////////////////////////////////////////////// + WITHDRAW/REDEEM FUNCTIONS + //////////////////////////////////////////////////////////////*/ - return shares; + /// @notice Withdraw assets from the vault + /// @dev Overrides the ERC4626 withdraw function to include unstaking of KWENTA + /// @param assets The amount of assets to withdraw + /// @param receiver The address to receive the assets + /// @param owner The owner of the shares + /// @return shares The amount of shares burned + function withdraw(uint256 assets, address receiver, address owner) public virtual override returns (uint256) { + _unstakeKWENTA(assets); + return super.withdraw(assets, receiver, owner); } - /** @dev See {IERC4626-redeem}. */ + /// @notice Redeem shares of the vault + /// @dev Overrides the ERC4626 redeem function to include unstaking of KWENTA + /// @param shares The amount of shares to redeem + /// @param receiver The address to receive the assets + /// @param owner The owner of the shares + /// @return assets The amount of assets withdrawn function redeem(uint256 shares, address receiver, address owner) public virtual override returns (uint256) { - uint256 maxShares = maxRedeem(owner); - if (shares > maxShares) { - revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); - } - uint256 assets = previewRedeem(shares); - _withdraw(_msgSender(), receiver, owner, assets, shares); + _unstakeKWENTA(assets); + return super.redeem(shares, receiver, owner); + } - return assets; + /*////////////////////////////////////////////////////////////// + STAKING MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + /// @notice Collect rewards and stake all available KWENTA + /// @dev This function is called after every deposit and mint operation + function _collectAndStakeRewards() internal { + STAKING_REWARDS.getReward(); + + uint256 totalToStake = KWENTA.balanceOf(address(this)); + if (totalToStake > 0) { + STAKING_REWARDS.stake(totalToStake); + } + } + + /// @notice Unstake KWENTA tokens + /// @dev This function is called before withdrawals and redemptions + /// @param kwentaAmount The amount of KWENTA to unstake + function _unstakeKWENTA(uint256 kwentaAmount) internal { + STAKING_REWARDS.unstake(kwentaAmount); } } diff --git a/src/interfaces/IStakingRewardsV2.sol b/src/interfaces/IStakingRewardsV2.sol deleted file mode 100644 index 7a94cf6..0000000 --- a/src/interfaces/IStakingRewardsV2.sol +++ /dev/null @@ -1,303 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.25; - -interface IStakingRewardsV2 { - /*////////////////////////////////////////////////////////////// - STRUCTS - //////////////////////////////////////////////////////////////*/ - - /// @notice A checkpoint for tracking values at a given timestamp - struct Checkpoint { - // The timestamp when the value was generated - uint64 ts; - // The block number when the value was generated - uint64 blk; - // The value of the checkpoint - /// @dev will not overflow unless it value reaches 340 quintillion - /// This number should be impossible to reach with the total supply of $KWENTA - uint128 value; - } - - /*/////////////////////////////////////////////////////////////// - INITIALIZER - ///////////////////////////////////////////////////////////////*/ - - /// @notice Initializes the contract - /// @param _owner: owner of this contract - /// @dev this function should be called via proxy, not via direct contract interaction - function initialize(address _owner) external; - - /*////////////////////////////////////////////////////////////// - Views - //////////////////////////////////////////////////////////////*/ - // token state - - /// @dev returns staked tokens which will likely not be equal to total tokens - /// in the contract since reward and staking tokens are the same - /// @return total amount of tokens that are being staked - function totalSupply() external view returns (uint256); - - // staking state - - /// @notice Returns the total number of staked tokens for a user - /// the sum of all escrowed and non-escrowed tokens - /// @param _account: address of potential staker - /// @return amount of tokens staked by account - function balanceOf(address _account) external view returns (uint256); - - /// @notice Getter function for number of staked escrow tokens - /// @param _account address to check the escrowed tokens staked - /// @return amount of escrowed tokens staked - function escrowedBalanceOf(address _account) external view returns (uint256); - - /// @notice Getter function for number of staked non-escrow tokens - /// @param _account address to check the non-escrowed tokens staked - /// @return amount of non-escrowed tokens staked - function nonEscrowedBalanceOf(address _account) external view returns (uint256); - - /// @notice Getter function for the total number of escrowed tokens that are not not staked - /// @param _account: address to check - /// @return amount of tokens escrowed but not staked - function unstakedEscrowedBalanceOf(address _account) external view returns (uint256); - - // rewards - - /// @notice calculate the total rewards for one duration based on the current rate - /// @return rewards for the duration specified by rewardsDuration - function getRewardForDuration() external view returns (uint256); - - /// @notice calculate running sum of reward per total tokens staked - /// at this specific time - /// @return running sum of reward per total tokens staked - function rewardPerToken() external view returns (uint256); - - /// @notice get the last time a reward is applicable for a given user - /// @return timestamp of the last time rewards are applicable - function lastTimeRewardApplicable() external view returns (uint256); - - /// @notice determine how much reward token an account has earned thus far - /// @param _account: address of account earned amount is being calculated for - function earned(address _account) external view returns (uint256); - - // checkpointing - - /// @notice get the number of balances checkpoints for an account - /// @param _account: address of account to check - /// @return number of balances checkpoints - function balancesCheckpointsLength(address _account) external view returns (uint256); - - /// @notice get the number of escrowed balance checkpoints for an account - /// @param _account: address of account to check - /// @return number of escrowed balance checkpoints - function escrowedBalancesCheckpointsLength(address _account) external view returns (uint256); - - /// @notice get the number of total supply checkpoints - /// @return number of total supply checkpoints - function totalSupplyCheckpointsLength() external view returns (uint256); - - /// @notice get a users balance at a given timestamp - /// @param _account: address of account to check - /// @param _timestamp: timestamp to check - /// @return balance at given timestamp - /// @dev if called with a timestamp that equals the current block timestamp, then the function might return inconsistent - /// values as further transactions changing the balances can still occur within the same block. - function balanceAtTime(address _account, uint256 _timestamp) external view returns (uint256); - - /// @notice get a users escrowed balance at a given timestamp - /// @param _account: address of account to check - /// @param _timestamp: timestamp to check - /// @return escrowed balance at given timestamp - /// @dev if called with a timestamp that equals the current block timestamp, then the function might return inconsistent - /// values as further transactions changing the balances can still occur within the same block. - function escrowedBalanceAtTime(address _account, uint256 _timestamp) - external - view - returns (uint256); - - /// @notice get the total supply at a given timestamp - /// @param _timestamp: timestamp to check - /// @return total supply at given timestamp - /// @dev if called with a timestamp that equals the current block timestamp, then the function might return inconsistent - /// values as further transactions changing the balances can still occur within the same block. - function totalSupplyAtTime(uint256 _timestamp) external view returns (uint256); - - /*////////////////////////////////////////////////////////////// - Mutative - //////////////////////////////////////////////////////////////*/ - // Staking/Unstaking - - /// @notice stake token - /// @param _amount: amount to stake - /// @dev updateReward() called prior to function logic - function stake(uint256 _amount) external; - - /// @notice unstake token - /// @param _amount: amount to unstake - /// @dev updateReward() called prior to function logic - function unstake(uint256 _amount) external; - - /// @notice stake escrowed token - /// @param _amount: amount to stake - /// @dev updateReward() called prior to function logic - function stakeEscrow(uint256 _amount) external; - - /// @notice unstake escrowed token - /// @param _amount: amount to unstake - /// @dev updateReward() called prior to function logic - function unstakeEscrow(uint256 _amount) external; - - /// @notice unstake escrowed token on behalf of another account - /// @param _account: address of account to unstake from - /// @param _amount: amount to unstake - /// @dev this function is used to allow tokens to be vested at any time by RewardEscrowV2 - function unstakeEscrowAdmin(address _account, uint256 _amount) external; - - /// @notice unstake all available staked non-escrowed tokens and - /// claim any rewards - function exit() external; - - // claim rewards - - /// @notice caller claims any rewards generated from staking - /// @dev rewards are escrowed in RewardEscrow - /// @dev updateReward() called prior to function logic - function getReward() external; - - /// @notice claim rewards for an account and stake them - function compound() external; - - // delegation - - /// @notice approve an operator to collect rewards and stake escrow on behalf of the sender - /// @param operator: address of operator to approve - /// @param approved: whether or not to approve the operator - function approveOperator(address operator, bool approved) external; - - /// @notice stake escrowed token on behalf of another account - /// @param _account: address of account to stake on behalf of - /// @param _amount: amount to stake - function stakeEscrowOnBehalf(address _account, uint256 _amount) external; - - /// @notice caller claims any rewards generated from staking on behalf of another account - /// The rewards will be escrowed in RewardEscrow with the account as the beneficiary - /// @param _account: address of account to claim rewards for - function getRewardOnBehalf(address _account) external; - - /// @notice claim and stake rewards on behalf of another account - /// @param _account: address of account to claim and stake rewards for - function compoundOnBehalf(address _account) external; - - // settings - - /// @notice configure reward rate - /// @param _reward: amount of token to be distributed over a period - /// @dev updateReward() called prior to function logic (with zero address) - function notifyRewardAmount(uint256 _reward) external; - - /// @notice set rewards duration - /// @param _rewardsDuration: denoted in seconds - function setRewardsDuration(uint256 _rewardsDuration) external; - - // pausable - - /// @dev Triggers stopped state - function pauseStakingRewards() external; - - /// @dev Returns to normal state. - function unpauseStakingRewards() external; - - // misc. - - /// @notice added to support recovering LP Rewards from other systems - /// such as BAL to be distributed to holders - /// @param tokenAddress: address of token to be recovered - /// @param tokenAmount: amount of token to be recovered - function recoverERC20(address tokenAddress, uint256 tokenAmount) external; - - /*/////////////////////////////////////////////////////////////// - EVENTS - ///////////////////////////////////////////////////////////////*/ - - /// @notice update reward rate - /// @param reward: amount to be distributed over applicable rewards duration - event RewardAdded(uint256 reward); - - /// @notice emitted when user stakes tokens - /// @param user: staker address - /// @param amount: amount staked - event Staked(address indexed user, uint256 amount); - - /// @notice emitted when user unstakes tokens - /// @param user: address of user unstaking - /// @param amount: amount unstaked - event Unstaked(address indexed user, uint256 amount); - - /// @notice emitted when escrow staked - /// @param user: owner of escrowed tokens address - /// @param amount: amount staked - event EscrowStaked(address indexed user, uint256 amount); - - /// @notice emitted when staked escrow tokens are unstaked - /// @param user: owner of escrowed tokens address - /// @param amount: amount unstaked - event EscrowUnstaked(address user, uint256 amount); - - /// @notice emitted when user claims rewards - /// @param user: address of user claiming rewards - /// @param reward: amount of reward token claimed - event RewardPaid(address indexed user, uint256 reward); - - /// @notice emitted when rewards duration changes - /// @param newDuration: denoted in seconds - event RewardsDurationUpdated(uint256 newDuration); - - /// @notice emitted when tokens are recovered from this contract - /// @param token: address of token recovered - /// @param amount: amount of token recovered - event Recovered(address token, uint256 amount); - - /// @notice emitted when an operator is approved - /// @param owner: owner of tokens - /// @param operator: address of operator - /// @param approved: whether or not operator is approved - event OperatorApproved(address owner, address operator, bool approved); - - /*////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////*/ - - /// @notice error someone other than reward escrow calls an onlyRewardEscrow function - error OnlyRewardEscrow(); - - /// @notice error someone other than the rewards notifier calls an onlyRewardsNotifier function - error OnlyRewardsNotifier(); - - /// @notice cannot set this value to the zero address - error ZeroAddress(); - - /// @notice error when user tries to stake/unstake 0 tokens - error AmountZero(); - - /// @notice the user does not have enough tokens to unstake that amount - /// @param availableBalance: amount of tokens available to withdraw - error InsufficientBalance(uint256 availableBalance); - - /// @notice error when trying to stakeEscrow more than the unstakedEscrow available - /// @param unstakedEscrow amount of unstaked escrow - error InsufficientUnstakedEscrow(uint256 unstakedEscrow); - - /// @notice previous rewards period must be complete before changing the duration for the new period - error RewardsPeriodNotComplete(); - - /// @notice recovering the staking token is not allowed - error CannotRecoverStakingToken(); - - /// @notice error when trying to set a rewards duration that is too short - error RewardsDurationCannotBeZero(); - - /// @notice the caller is not approved to take this action - error NotApproved(); - - /// @notice attempted to approve self as an operator - error CannotApproveSelf(); -} From c77ba4f6d22b3df909b4906102dd7795e23850eb Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 10 Sep 2024 17:03:48 +0200 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=94=80=20Resolve=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- remappings.txt | 3 +- script/Deploy.s.sol | 3 +- test/KSXVault.t.sol | 48 +++++++++++++++---------------- test/mocks/MockERC20.sol | 19 ++++++++++++ test/mocks/MockStakingRewards.sol | 36 +++++++++++++++++++++++ test/utils/Bootstrap.sol | 11 +++++-- 6 files changed, 91 insertions(+), 29 deletions(-) create mode 100644 test/mocks/MockERC20.sol create mode 100644 test/mocks/MockStakingRewards.sol diff --git a/remappings.txt b/remappings.txt index eede3c1..75cdc39 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,2 @@ -@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ \ No newline at end of file +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +@token/=lib/token/contracts/ \ No newline at end of file diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index d7415e1..7c770f0 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -23,12 +23,13 @@ contract Setup is Script { function deploySystem( address token, + address stakingRewards, uint8 decimalOffset ) public returns (KSXVault ksxVault) { - ksxVault = new KSXVault(token, decimalOffset); + ksxVault = new KSXVault(token, stakingRewards, decimalOffset); // deploy ERC1967 proxy and set implementation to ksxVault Proxy proxy = new Proxy(address(ksxVault), ""); diff --git a/test/KSXVault.t.sol b/test/KSXVault.t.sol index 567937f..cce4144 100644 --- a/test/KSXVault.t.sol +++ b/test/KSXVault.t.sol @@ -4,17 +4,32 @@ pragma solidity 0.8.25; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Test} from "forge-std/Test.sol"; import {Bootstrap, KSXVault} from "test/utils/Bootstrap.sol"; +import {MockERC20} from "test/mocks/MockERC20.sol"; +import {MockStakingRewards} from "test/mocks/MockStakingRewards.sol"; contract KSXVaultTest is Bootstrap { MockERC20 depositToken; + MockStakingRewards stakingRewards; function setUp() public { + depositToken = new MockERC20("Deposit Token", "DT"); - initializeLocal(address(depositToken), DECIMAL_OFFSET); + stakingRewards = new MockStakingRewards(address(depositToken)); + initializeLocal(address(depositToken), address(stakingRewards), DECIMAL_OFFSET); depositToken.mint(alice, 10 ether); depositToken.mint(bob, 10 ether); + + // vm.prank(alice); + // depositToken.approve(address(ksxVault), type(uint256).max); + + // vm.prank(bob); + // depositToken.approve(address(ksxVault), type(uint256).max); + + // Give infinite approval to the staking rewards contract for the vault + vm.prank(address(ksxVault)); + depositToken.approve(address(stakingRewards), type(uint256).max); } // Asserts decimals offset is correctly set to 3 @@ -30,7 +45,7 @@ contract KSXVaultTest is Bootstrap { depositToken.approve(address(ksxVault), amount); ksxVault.deposit(1 ether, alice); assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); - assertEq(depositToken.balanceOf(address(ksxVault)), amount); + assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), amount); vm.stopPrank(); } @@ -44,7 +59,7 @@ contract KSXVaultTest is Bootstrap { ksxVault.mint(1 ether, alice); assertEq(ksxVault.balanceOf(alice), amount); assertEq( - depositToken.balanceOf(address(ksxVault)), + stakingRewards.stakedBalanceOf(address(ksxVault)), amount / (10 ** ksxVault.offset()) ); vm.stopPrank(); @@ -58,11 +73,11 @@ contract KSXVaultTest is Bootstrap { depositToken.approve(address(ksxVault), amount); ksxVault.deposit(amount, alice); assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); - assertEq(depositToken.balanceOf(address(ksxVault)), amount); + assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), amount); ksxVault.withdraw(amount, alice, alice); assertEq(ksxVault.balanceOf(alice), 0); - assertEq(depositToken.balanceOf(address(ksxVault)), 0); + assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), 0); assertEq(depositToken.balanceOf(alice), 10 ether); vm.stopPrank(); } @@ -72,32 +87,17 @@ contract KSXVaultTest is Bootstrap { vm.startPrank(alice); depositToken.approve(address(ksxVault), amount); ksxVault.mint(1 ether, alice); - assertEq(ksxVault.balanceOf(alice), amount); + assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), amount / 1000); assertEq( - depositToken.balanceOf(address(ksxVault)), + stakingRewards.stakedBalanceOf(address(ksxVault)), amount / (10 ** ksxVault.offset()) ); ksxVault.redeem(amount, alice, alice); assertEq(ksxVault.balanceOf(alice), 0); - assertEq(depositToken.balanceOf(address(ksxVault)), 0); - assertEq(depositToken.balanceOf(alice), 10 ether); + // assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), 0); + // assertEq(depositToken.balanceOf(alice), 10 ether); vm.stopPrank(); } } - -contract MockERC20 is ERC20 { - - constructor( - string memory name_, - string memory symbol_ - ) - ERC20(name_, symbol_) - {} - - function mint(address to, uint256 amount) external { - _mint(to, amount); - } - -} diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol new file mode 100644 index 0000000..17f05c3 --- /dev/null +++ b/test/mocks/MockERC20.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + + constructor( + string memory name_, + string memory symbol_ + ) + ERC20(name_, symbol_) + {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + +} diff --git a/test/mocks/MockStakingRewards.sol b/test/mocks/MockStakingRewards.sol new file mode 100644 index 0000000..53f17f4 --- /dev/null +++ b/test/mocks/MockStakingRewards.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockStakingRewards { + IERC20 public stakingToken; + mapping(address => uint256) public stakedBalances; + uint256 public totalStaked; + + constructor(address _stakingToken) { + stakingToken = IERC20(_stakingToken); + } + + function stake(uint256 amount) external { + require(stakingToken.transferFrom(msg.sender, address(this), amount), "Stake failed"); + stakedBalances[msg.sender] += amount; + totalStaked += amount; + } + + function unstake(uint256 amount) external { + require(stakedBalances[msg.sender] >= amount, "Insufficient staked balance"); + stakedBalances[msg.sender] -= amount; + totalStaked -= amount; + require(stakingToken.transfer(msg.sender, amount), "Unstake transfer failed"); + } + + function getReward() external { + // For simplicity, we're not implementing reward logic in this mock + } + + // Helper function to check staked balance + function stakedBalanceOf(address account) external view returns (uint256) { + return stakedBalances[account]; + } +} \ No newline at end of file diff --git a/test/utils/Bootstrap.sol b/test/utils/Bootstrap.sol index fdf21fe..25126ae 100644 --- a/test/utils/Bootstrap.sol +++ b/test/utils/Bootstrap.sol @@ -11,6 +11,7 @@ import { } from "script/Deploy.s.sol"; import {KSXVault} from "src/KSXVault.sol"; import {Constants} from "test/utils/Constants.sol"; +import {IStakingRewardsV2} from "@token/interfaces/IStakingRewardsV2.sol"; contract Bootstrap is Test, Constants { @@ -24,16 +25,19 @@ contract Bootstrap is Test, Constants { IERC20 public TOKEN; + IStakingRewardsV2 public STAKING_REWARDS; + // testing addresses address constant alice = address(0xAAAA); address constant bob = address(0xBBBB); - function initializeLocal(address _token, uint8 _decimalsOffset) internal { + function initializeLocal(address _token, address _stakingRewards, uint8 _decimalsOffset) internal { BootstrapLocal bootstrap = new BootstrapLocal(); - (address ksxVaultAddress) = bootstrap.init(_token, _decimalsOffset); + (address ksxVaultAddress) = bootstrap.init(_token, _stakingRewards, _decimalsOffset); decimalsOffset = _decimalsOffset; TOKEN = IERC20(_token); + STAKING_REWARDS= IStakingRewardsV2(_stakingRewards); ksxVault = KSXVault(ksxVaultAddress); } @@ -43,12 +47,13 @@ contract BootstrapLocal is Setup { function init( address _token, + address _stakingRewards, uint8 _decimalsOffset ) public returns (address) { - (KSXVault ksxvault) = Setup.deploySystem(_token, _decimalsOffset); + (KSXVault ksxvault) = Setup.deploySystem(_token, _stakingRewards, _decimalsOffset); return (address(ksxvault)); } From 98e6e0b897e3971cfe6df5690bf123385a49bb01 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Mon, 28 Oct 2024 17:34:04 +0100 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=91=B7=20fix:=20vault=20withdraw=20?= =?UTF-8?q?and=20totalAssets=20accounting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KSXVault.sol | 81 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/src/KSXVault.sol b/src/KSXVault.sol index ff2d4fb..b05bb42 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -26,7 +26,7 @@ contract KSXVault is ERC4626 { IStakingRewardsV2 internal immutable STAKING_REWARDS; /// @notice KWENTA TOKEN - /// @dev The underlying asset of this vault + /// @dev The underlying asset of this vault ERC20 private immutable KWENTA; /*////////////////////////////////////////////////////////////// @@ -58,27 +58,55 @@ contract KSXVault is ERC4626 { return offset; } + /// @notice Returns the total amount of underlying assets held by the vault + /// @dev Overrides the ERC4626 totalAssets function to include staked + /// assets. + /// Since all assets are automatically staked after deposit/mint operations, + /// we only need to return the staked balance. + /// @return The total amount of assets in the vault (all staked) + function totalAssets() public view virtual override returns (uint256) { + return STAKING_REWARDS.balanceOf(address(this)); + } + /*////////////////////////////////////////////////////////////// DEPOSIT/MINT FUNCTIONS //////////////////////////////////////////////////////////////*/ /// @notice Deposit assets into the vault - /// @dev Overrides the ERC4626 deposit function to include reward collection and staking + /// @dev Overrides the ERC4626 deposit function to include reward collection + /// and staking /// @param assets The amount of assets to deposit /// @param receiver The address to receive the minted shares /// @return shares The amount of shares minted - function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { + function deposit( + uint256 assets, + address receiver + ) + public + virtual + override + returns (uint256) + { uint256 shares = super.deposit(assets, receiver); _collectAndStakeRewards(); return shares; } /// @notice Mint shares of the vault - /// @dev Overrides the ERC4626 mint function to include reward collection and staking + /// @dev Overrides the ERC4626 mint function to include reward collection + /// and staking /// @param shares The amount of shares to mint /// @param receiver The address to receive the minted shares /// @return assets The amount of assets deposited - function mint(uint256 shares, address receiver) public virtual override returns (uint256) { + function mint( + uint256 shares, + address receiver + ) + public + virtual + override + returns (uint256) + { uint256 assets = super.mint(shares, receiver); _collectAndStakeRewards(); return assets; @@ -88,24 +116,49 @@ contract KSXVault is ERC4626 { WITHDRAW/REDEEM FUNCTIONS //////////////////////////////////////////////////////////////*/ - /// @notice Withdraw assets from the vault - /// @dev Overrides the ERC4626 withdraw function to include unstaking of KWENTA + /// @notice Internal withdraw function that handles the actual token + /// movement + /// @dev Overridden to handle unstaking before the ERC4626 withdraw logic + /// @param caller The address that initiated the withdrawal + /// @param receiver The address that will receive the assets + /// @param owner The address that owns the shares /// @param assets The amount of assets to withdraw - /// @param receiver The address to receive the assets - /// @param owner The owner of the shares - /// @return shares The amount of shares burned - function withdraw(uint256 assets, address receiver, address owner) public virtual override returns (uint256) { + /// @param shares The amount of shares to burn + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) + internal + virtual + override + { + // Unstake the KWENTA tokens first _unstakeKWENTA(assets); - return super.withdraw(assets, receiver, owner); + + // Let ERC4626 handle the rest + super._withdraw(caller, receiver, owner, assets, shares); } /// @notice Redeem shares of the vault - /// @dev Overrides the ERC4626 redeem function to include unstaking of KWENTA + /// @dev Overrides the ERC4626 redeem function to include unstaking of + /// KWENTA /// @param shares The amount of shares to redeem /// @param receiver The address to receive the assets /// @param owner The owner of the shares /// @return assets The amount of assets withdrawn - function redeem(uint256 shares, address receiver, address owner) public virtual override returns (uint256) { + function redeem( + uint256 shares, + address receiver, + address owner + ) + public + virtual + override + returns (uint256) + { uint256 assets = previewRedeem(shares); _unstakeKWENTA(assets); return super.redeem(shares, receiver, owner); From fb5b902dd93f7e2795a7793ba4591a6f4363dcf5 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Mon, 28 Oct 2024 17:34:38 +0100 Subject: [PATCH 14/20] =?UTF-8?q?=F0=9F=91=B7=20match=20stakingrewardsv2?= =?UTF-8?q?=20balanceOf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/mocks/MockStakingRewards.sol | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/mocks/MockStakingRewards.sol b/test/mocks/MockStakingRewards.sol index 53f17f4..0470e9d 100644 --- a/test/mocks/MockStakingRewards.sol +++ b/test/mocks/MockStakingRewards.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.25; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract MockStakingRewards { + IERC20 public stakingToken; mapping(address => uint256) public stakedBalances; uint256 public totalStaked; @@ -13,16 +14,23 @@ contract MockStakingRewards { } function stake(uint256 amount) external { - require(stakingToken.transferFrom(msg.sender, address(this), amount), "Stake failed"); + require( + stakingToken.transferFrom(msg.sender, address(this), amount), + "Stake failed" + ); stakedBalances[msg.sender] += amount; totalStaked += amount; } function unstake(uint256 amount) external { - require(stakedBalances[msg.sender] >= amount, "Insufficient staked balance"); + require( + stakedBalances[msg.sender] >= amount, "Insufficient staked balance" + ); stakedBalances[msg.sender] -= amount; totalStaked -= amount; - require(stakingToken.transfer(msg.sender, amount), "Unstake transfer failed"); + require( + stakingToken.transfer(msg.sender, amount), "Unstake transfer failed" + ); } function getReward() external { @@ -30,7 +38,8 @@ contract MockStakingRewards { } // Helper function to check staked balance - function stakedBalanceOf(address account) external view returns (uint256) { + function balanceOf(address account) external view returns (uint256) { return stakedBalances[account]; } -} \ No newline at end of file + +} From 0cea36b9346644daeec7ace5ac7a791624531a56 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Mon, 28 Oct 2024 17:35:04 +0100 Subject: [PATCH 15/20] =?UTF-8?q?=E2=9C=A8=20prettify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/utils/Bootstrap.sol | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/utils/Bootstrap.sol b/test/utils/Bootstrap.sol index 25126ae..d063e6b 100644 --- a/test/utils/Bootstrap.sol +++ b/test/utils/Bootstrap.sol @@ -2,6 +2,8 @@ pragma solidity 0.8.25; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IStakingRewardsV2} from "@token/interfaces/IStakingRewardsV2.sol"; import {Test} from "lib/forge-std/src/Test.sol"; import {console2} from "lib/forge-std/src/console2.sol"; import { @@ -11,7 +13,6 @@ import { } from "script/Deploy.s.sol"; import {KSXVault} from "src/KSXVault.sol"; import {Constants} from "test/utils/Constants.sol"; -import {IStakingRewardsV2} from "@token/interfaces/IStakingRewardsV2.sol"; contract Bootstrap is Test, Constants { @@ -31,13 +32,20 @@ contract Bootstrap is Test, Constants { address constant alice = address(0xAAAA); address constant bob = address(0xBBBB); - function initializeLocal(address _token, address _stakingRewards, uint8 _decimalsOffset) internal { + function initializeLocal( + address _token, + address _stakingRewards, + uint8 _decimalsOffset + ) + internal + { BootstrapLocal bootstrap = new BootstrapLocal(); - (address ksxVaultAddress) = bootstrap.init(_token, _stakingRewards, _decimalsOffset); + (address ksxVaultAddress) = + bootstrap.init(_token, _stakingRewards, _decimalsOffset); decimalsOffset = _decimalsOffset; TOKEN = IERC20(_token); - STAKING_REWARDS= IStakingRewardsV2(_stakingRewards); + STAKING_REWARDS = IStakingRewardsV2(_stakingRewards); ksxVault = KSXVault(ksxVaultAddress); } @@ -53,7 +61,8 @@ contract BootstrapLocal is Setup { public returns (address) { - (KSXVault ksxvault) = Setup.deploySystem(_token, _stakingRewards, _decimalsOffset); + (KSXVault ksxvault) = + Setup.deploySystem(_token, _stakingRewards, _decimalsOffset); return (address(ksxvault)); } From 8668cfee78836e85535a8bb9933dc9da3716bfb8 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Mon, 28 Oct 2024 17:37:09 +0100 Subject: [PATCH 16/20] =?UTF-8?q?=E2=9C=85=20add=20vault=20operations=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/KSXVault.t.sol | 163 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 9 deletions(-) diff --git a/test/KSXVault.t.sol b/test/KSXVault.t.sol index cce4144..abe3896 100644 --- a/test/KSXVault.t.sol +++ b/test/KSXVault.t.sol @@ -3,9 +3,11 @@ pragma solidity 0.8.25; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Test} from "forge-std/Test.sol"; -import {Bootstrap, KSXVault} from "test/utils/Bootstrap.sol"; + +import {console2} from "forge-std/console2.sol"; import {MockERC20} from "test/mocks/MockERC20.sol"; import {MockStakingRewards} from "test/mocks/MockStakingRewards.sol"; +import {Bootstrap, KSXVault} from "test/utils/Bootstrap.sol"; contract KSXVaultTest is Bootstrap { @@ -13,10 +15,11 @@ contract KSXVaultTest is Bootstrap { MockStakingRewards stakingRewards; function setUp() public { - depositToken = new MockERC20("Deposit Token", "DT"); stakingRewards = new MockStakingRewards(address(depositToken)); - initializeLocal(address(depositToken), address(stakingRewards), DECIMAL_OFFSET); + initializeLocal( + address(depositToken), address(stakingRewards), DECIMAL_OFFSET + ); depositToken.mint(alice, 10 ether); depositToken.mint(bob, 10 ether); @@ -39,13 +42,100 @@ contract KSXVaultTest is Bootstrap { // Asserts correct deposit at 1000 shares ratio // Converts asset values to shares and deposits assets into the vault + // asserts deposited kwenta is correctly staked in the staking rewards + // contract function test_vault_deposit() public { uint256 amount = 1 ether; vm.startPrank(alice); depositToken.approve(address(ksxVault), amount); ksxVault.deposit(1 ether, alice); assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); - assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), amount); + assertEq(stakingRewards.balanceOf(address(ksxVault)), amount); + vm.stopPrank(); + } + + // Asserts correct deposit at 1000 shares ratio + function test_vault_deposit_1000_ratio() public { + uint256 depositAmount = 1 ether; + uint256 expectedShares = 1000 ether; // We expect 1000x the deposit + // amount + + vm.startPrank(alice); + depositToken.approve(address(ksxVault), depositAmount); + ksxVault.deposit(depositAmount, alice); + + assertEq(ksxVault.balanceOf(alice), expectedShares); + vm.stopPrank(); + } + + // Asserts correct deposit at 1000 shares ratio Fuzzing depositAmount + function test_vault_deposit_1000_ratio_Fuzz(uint256 depositAmount) public { + depositAmount = bound(depositAmount, 1, 1_000_000 ether); + + depositToken.mint(alice, depositAmount); + + vm.startPrank(alice); + depositToken.approve(address(ksxVault), depositAmount); + ksxVault.deposit(depositAmount, alice); + + uint256 expectedKSX = depositAmount * 1000; + + // Verify the initial 1:1000 ratio holds for any amount + assertEq(ksxVault.balanceOf(alice), expectedKSX); + + vm.stopPrank(); + } + + // Asserts the 1:1000 ratio is maintained when no rewards have accrued + function test_consecutive_deposits() public { + // First deposit: 1 KWENTA + uint256 firstDeposit = 1 ether; + + vm.startPrank(alice); + + // First deposit + depositToken.approve(address(ksxVault), firstDeposit); + uint256 firstShares = ksxVault.deposit(firstDeposit, alice); + + // Verify first deposit results + assertEq( + firstShares, 1000 ether, "First deposit should mint at 1:1000 ratio" + ); + assertEq( + stakingRewards.balanceOf(address(ksxVault)), + firstDeposit, + "Should have staked first deposit" + ); + + // Second deposit: 2 KWENTA + uint256 secondDeposit = 2 ether; + depositToken.approve(address(ksxVault), secondDeposit); + uint256 secondShares = ksxVault.deposit(secondDeposit, alice); + + // Verify second deposit results + assertEq( + secondShares, + 2000 ether, + "Second deposit should mint at 1:1000 ratio" + ); + assertEq( + stakingRewards.balanceOf(address(ksxVault)), + firstDeposit + secondDeposit, + "Should have staked both deposits" + ); + + // Verify final state + assertEq( + ksxVault.balanceOf(alice), + 3000 ether, + "Should have total of 3000 KSX" + ); + assertEq( + stakingRewards.balanceOf(address(ksxVault)), + 3 ether, + "Should have 3 KWENTA staked total" + ); + vm.stopPrank(); } @@ -59,26 +149,81 @@ contract KSXVaultTest is Bootstrap { ksxVault.mint(1 ether, alice); assertEq(ksxVault.balanceOf(alice), amount); assertEq( - stakingRewards.stakedBalanceOf(address(ksxVault)), + stakingRewards.balanceOf(address(ksxVault)), amount / (10 ** ksxVault.offset()) ); vm.stopPrank(); } + // Asserts correct mint at 1000 shares ratio + function test_vault_mint_1000_ratio() public { + uint256 sharesToMint = 1000 ether; // minting 1000 KSX shares requires 1 + // KWENTA + uint256 depositTokenNeeded = 1 ether; + + uint256 initialDepositTokenBalance = depositToken.balanceOf(alice); + + vm.startPrank(alice); + depositToken.approve(address(ksxVault), depositTokenNeeded); + ksxVault.mint(sharesToMint, alice); + + // Verify we got the correct number of shares + assertEq(ksxVault.balanceOf(alice), sharesToMint); + assertEq( + depositToken.balanceOf(alice), + initialDepositTokenBalance - depositTokenNeeded + ); + vm.stopPrank(); + } + + // Asserts correct mint at 1000 shares ratio Fuzzing sharesToMint + function test_vault_mint_1000_ratio_Fuzz(uint256 sharesToMint) public { + sharesToMint = bound(sharesToMint, 1000 ether, 100_000_000 ether); + + uint256 depositTokenNeeded = (sharesToMint + 999) / 1000; + + // Ensure alice has enough tokens + depositToken.mint(alice, depositTokenNeeded); + + uint256 initialDepositTokenBalance = depositToken.balanceOf(alice); + + vm.startPrank(alice); + depositToken.approve(address(ksxVault), depositTokenNeeded); + ksxVault.mint(sharesToMint, alice); + + // Verify we got the correct number of shares + assertEq(ksxVault.balanceOf(alice), sharesToMint); + + assertEq( + depositToken.balanceOf(alice), + initialDepositTokenBalance - depositTokenNeeded + ); + vm.stopPrank(); + } + // Withdraws a specified amount of assets from the vault by burning the // equivalent shares function test_withdraw() public { uint256 amount = 1 ether; + vm.startPrank(alice); + + // Approve and deposit depositToken.approve(address(ksxVault), amount); ksxVault.deposit(amount, alice); + + // Verify initial state assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); - assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), amount); + assertEq(stakingRewards.balanceOf(address(ksxVault)), amount); + // Withdraw ksxVault.withdraw(amount, alice, alice); + + // Verify final state assertEq(ksxVault.balanceOf(alice), 0); - assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), 0); + assertEq(stakingRewards.balanceOf(address(ksxVault)), 0); assertEq(depositToken.balanceOf(alice), 10 ether); + vm.stopPrank(); } @@ -87,9 +232,9 @@ contract KSXVaultTest is Bootstrap { vm.startPrank(alice); depositToken.approve(address(ksxVault), amount); ksxVault.mint(1 ether, alice); - assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), amount / 1000); + assertEq(stakingRewards.balanceOf(address(ksxVault)), amount / 1000); assertEq( - stakingRewards.stakedBalanceOf(address(ksxVault)), + stakingRewards.balanceOf(address(ksxVault)), amount / (10 ** ksxVault.offset()) ); From 7b9b12fb5f778f4ffb8ba1d084ffa221be4a483c Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 29 Oct 2024 17:28:35 +0100 Subject: [PATCH 17/20] =?UTF-8?q?=F0=9F=91=B7=20disable=20mint/withdraw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KSXVault.sol | 88 ++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/src/KSXVault.sol b/src/KSXVault.sol index b05bb42..9578118 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -4,6 +4,8 @@ pragma solidity 0.8.25; import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {SafeERC20} from + "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IStakingRewardsV2} from "@token/interfaces/IStakingRewardsV2.sol"; /// @title KSXVault Contract @@ -92,38 +94,42 @@ contract KSXVault is ERC4626 { return shares; } - /// @notice Mint shares of the vault - /// @dev Overrides the ERC4626 mint function to include reward collection - /// and staking - /// @param shares The amount of shares to mint - /// @param receiver The address to receive the minted shares - /// @return assets The amount of assets deposited - function mint( - uint256 shares, - address receiver + /// @dev Disabled to enforce deposit/redeem pattern. Use deposit() instead. + function mint(uint256, address) public virtual override returns (uint256) { + revert("Disabled"); + } + + /*////////////////////////////////////////////////////////////// + WITHDRAW/REDEEM FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @dev Disabled to enforce deposit/redeem pattern. Use redeem() instead. + function withdraw( + uint256, + address, + address ) public virtual override returns (uint256) { - uint256 assets = super.mint(shares, receiver); - _collectAndStakeRewards(); - return assets; + revert("Disabled"); } - /*////////////////////////////////////////////////////////////// - WITHDRAW/REDEEM FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /// @notice Internal withdraw function that handles the actual token - /// movement - /// @dev Overridden to handle unstaking before the ERC4626 withdraw logic - /// @param caller The address that initiated the withdrawal - /// @param receiver The address that will receive the assets - /// @param owner The address that owns the shares - /// @param assets The amount of assets to withdraw - /// @param shares The amount of shares to burn + /// @notice Internal withdrawal mechanism for both withdraw and redeem + /// operations + /// @dev Overrides ERC4626 _withdraw to handle unstaking before token + /// transfers. + /// Order of operations is important: + /// 1. Burn shares (maintaining ratio) + /// 2. Unstake tokens + /// 3. Transfer tokens to receiver + /// @param caller Address that initiated the withdrawal + /// @param receiver Address that will receive the tokens + /// @param owner Address that owns the shares + /// @param assets Amount of underlying tokens to withdraw + /// @param shares Amount of shares to burn function _withdraw( address caller, address receiver, @@ -135,33 +141,19 @@ contract KSXVault is ERC4626 { virtual override { - // Unstake the KWENTA tokens first + // First check and burn shares + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + _burn(owner, shares); + + // Then unstake corresponding assets _unstakeKWENTA(assets); - // Let ERC4626 handle the rest - super._withdraw(caller, receiver, owner, assets, shares); - } + // Finally transfer assets + SafeERC20.safeTransfer(KWENTA, receiver, assets); - /// @notice Redeem shares of the vault - /// @dev Overrides the ERC4626 redeem function to include unstaking of - /// KWENTA - /// @param shares The amount of shares to redeem - /// @param receiver The address to receive the assets - /// @param owner The owner of the shares - /// @return assets The amount of assets withdrawn - function redeem( - uint256 shares, - address receiver, - address owner - ) - public - virtual - override - returns (uint256) - { - uint256 assets = previewRedeem(shares); - _unstakeKWENTA(assets); - return super.redeem(shares, receiver, owner); + emit Withdraw(caller, receiver, owner, assets, shares); } /*////////////////////////////////////////////////////////////// From c7e5aeaeb5b98b8604c09e1615aa243461b7e2fe Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 29 Oct 2024 17:29:00 +0100 Subject: [PATCH 18/20] =?UTF-8?q?=F0=9F=91=B7=20mock=20accrued=20rewards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/mocks/MockStakingRewards.sol | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/mocks/MockStakingRewards.sol b/test/mocks/MockStakingRewards.sol index 0470e9d..ce72f5d 100644 --- a/test/mocks/MockStakingRewards.sol +++ b/test/mocks/MockStakingRewards.sol @@ -42,4 +42,18 @@ contract MockStakingRewards { return stakedBalances[account]; } + /// @notice Simulates rewards being added to a staker's balance + /// @dev This function also increases the total staked amount and requires + /// the reward tokens to be transferred to this contract first + /// @param staker The address receiving the rewards + /// @param amount The amount of rewards to add + function addRewardToStaker(address staker, uint256 amount) external { + require( + stakingToken.balanceOf(address(this)) >= amount, + "Insufficient reward tokens" + ); + stakedBalances[staker] += amount; + totalStaked += amount; + } + } From c79d0168acf7130789951540a81445ba3d0b7cb1 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 29 Oct 2024 17:29:43 +0100 Subject: [PATCH 19/20] =?UTF-8?q?=E2=9C=85=20add=20comprehensive=20vault?= =?UTF-8?q?=20operation=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/KSXVault.t.sol | 300 +++++++++++++++++++++++++++++++------------- 1 file changed, 211 insertions(+), 89 deletions(-) diff --git a/test/KSXVault.t.sol b/test/KSXVault.t.sol index abe3896..f57942f 100644 --- a/test/KSXVault.t.sol +++ b/test/KSXVault.t.sol @@ -14,6 +14,11 @@ contract KSXVaultTest is Bootstrap { MockERC20 depositToken; MockStakingRewards stakingRewards; + // Custom error from ERC4626 + error ERC4626ExceededMaxRedeem( + address owner, uint256 shares, uint256 maxShares + ); + function setUp() public { depositToken = new MockERC20("Deposit Token", "DT"); stakingRewards = new MockStakingRewards(address(depositToken)); @@ -87,7 +92,7 @@ contract KSXVaultTest is Bootstrap { } // Asserts the 1:1000 ratio is maintained when no rewards have accrued - function test_consecutive_deposits() public { + function test_vault_deposits() public { // First deposit: 1 KWENTA uint256 firstDeposit = 1 ether; @@ -98,14 +103,8 @@ contract KSXVaultTest is Bootstrap { uint256 firstShares = ksxVault.deposit(firstDeposit, alice); // Verify first deposit results - assertEq( - firstShares, 1000 ether, "First deposit should mint at 1:1000 ratio" - ); - assertEq( - stakingRewards.balanceOf(address(ksxVault)), - firstDeposit, - "Should have staked first deposit" - ); + assertEq(firstShares, 1000 ether); + assertEq(stakingRewards.balanceOf(address(ksxVault)), firstDeposit); // Second deposit: 2 KWENTA uint256 secondDeposit = 2 ether; @@ -113,135 +112,258 @@ contract KSXVaultTest is Bootstrap { uint256 secondShares = ksxVault.deposit(secondDeposit, alice); // Verify second deposit results - assertEq( - secondShares, - 2000 ether, - "Second deposit should mint at 1:1000 ratio" - ); + assertEq(secondShares, 2000 ether); assertEq( stakingRewards.balanceOf(address(ksxVault)), - firstDeposit + secondDeposit, - "Should have staked both deposits" + firstDeposit + secondDeposit ); // Verify final state - assertEq( - ksxVault.balanceOf(alice), - 3000 ether, - "Should have total of 3000 KSX" - ); - assertEq( - stakingRewards.balanceOf(address(ksxVault)), - 3 ether, - "Should have 3 KWENTA staked total" - ); + assertEq(ksxVault.balanceOf(alice), 3000 ether); + assertEq(stakingRewards.balanceOf(address(ksxVault)), 3 ether); vm.stopPrank(); } - // Asserts correct mint at 1000 shares ratio - // Mints a specified number of shares and requires the equivalent asset - // value to be deposited - function test_vault_mint() public { - uint256 amount = 1 ether; + function test_vault_deposits_with_rewards_between() public { + // Initial setup + uint256 initialDeposit = 1 ether; + uint256 rewardsAmount = 0.5 ether; + + // First deposit from Alice vm.startPrank(alice); - depositToken.approve(address(ksxVault), amount); - ksxVault.mint(1 ether, alice); - assertEq(ksxVault.balanceOf(alice), amount); + depositToken.approve(address(ksxVault), initialDeposit); + uint256 aliceShares = ksxVault.deposit(initialDeposit, alice); + vm.stopPrank(); + + // Verify Alice's initial position (1:1000 ratio) + assertEq(aliceShares, 1000 ether); + assertEq(stakingRewards.balanceOf(address(ksxVault)), initialDeposit); + + // Simulate rewards accrual to the vault's staked balance + depositToken.mint(address(stakingRewards), rewardsAmount); + stakingRewards.addRewardToStaker(address(ksxVault), rewardsAmount); + + // Verify rewards were properly added assertEq( stakingRewards.balanceOf(address(ksxVault)), - amount / (10 ** ksxVault.offset()) + initialDeposit + rewardsAmount ); + + // Bob's deposit + uint256 bobDeposit = 1 ether; + + // Calculate expected shares for Bob + // At this point, ratio is no longer 1:1000 due to rewards: + // - Total assets = 1.5 KWENTA (1 initial + 0.5 rewards) + // - Total shares = 1000 KSX (Alice's shares) + // - Bob deposits 1 KWENTA, should receive: (1 * 1000) / 1.5 = + // 666.666... KSX + uint256 expectedBobShares = + (bobDeposit * 1000 ether) / (initialDeposit + rewardsAmount); + + vm.startPrank(bob); + depositToken.approve(address(ksxVault), bobDeposit); + uint256 bobShares = ksxVault.deposit(bobDeposit, bob); vm.stopPrank(); + + // Verify Bob's position + assertApproxEqRel( + bobShares, + expectedBobShares, + 0.001e18 // 0.1% tolerance for rounding + ); + + // Verify final states + assertEq( + stakingRewards.balanceOf(address(ksxVault)), + initialDeposit + rewardsAmount + bobDeposit + ); + + // Verify share distribution + assertEq(ksxVault.balanceOf(alice), 1000 ether); + assertEq(ksxVault.balanceOf(bob), bobShares); + + // Verify total shares + assertEq(ksxVault.totalSupply(), 1000 ether + bobShares); } - // Asserts correct mint at 1000 shares ratio - function test_vault_mint_1000_ratio() public { - uint256 sharesToMint = 1000 ether; // minting 1000 KSX shares requires 1 - // KWENTA - uint256 depositTokenNeeded = 1 ether; + function test_vault_deposit_more_than_balance() public { + uint256 aliceBalance = depositToken.balanceOf(alice); - uint256 initialDepositTokenBalance = depositToken.balanceOf(alice); + vm.startPrank(alice); + depositToken.approve(address(ksxVault), aliceBalance + 1 ether); + vm.expectRevert(); // ERC20: transfer amount exceeds balance + ksxVault.deposit(aliceBalance + 1 ether, alice); + vm.stopPrank(); + } + // Test that mint is disabled + function test_vault_mint_disabled() public { vm.startPrank(alice); - depositToken.approve(address(ksxVault), depositTokenNeeded); - ksxVault.mint(sharesToMint, alice); + vm.expectRevert("Disabled"); + ksxVault.mint(1000 ether, alice); + vm.stopPrank(); + } - // Verify we got the correct number of shares - assertEq(ksxVault.balanceOf(alice), sharesToMint); - assertEq( - depositToken.balanceOf(alice), - initialDepositTokenBalance - depositTokenNeeded - ); + // Test that withdraw is disabled + function test_withdraw_disabled() public { + vm.startPrank(alice); + vm.expectRevert("Disabled"); + ksxVault.withdraw(1 ether, alice, alice); vm.stopPrank(); } - // Asserts correct mint at 1000 shares ratio Fuzzing sharesToMint - function test_vault_mint_1000_ratio_Fuzz(uint256 sharesToMint) public { - sharesToMint = bound(sharesToMint, 1000 ether, 100_000_000 ether); + function test_vault_redeem() public { + uint256 depositAmount = 1 ether; + vm.startPrank(alice); - uint256 depositTokenNeeded = (sharesToMint + 999) / 1000; + uint256 initialBalance = depositToken.balanceOf(alice); - // Ensure alice has enough tokens - depositToken.mint(alice, depositTokenNeeded); + // Deposit + depositToken.approve(address(ksxVault), depositAmount); + uint256 sharesMinted = ksxVault.deposit(depositAmount, alice); - uint256 initialDepositTokenBalance = depositToken.balanceOf(alice); + // Verify initial state + assertEq(sharesMinted, depositAmount * (10 ** ksxVault.offset())); + assertEq(stakingRewards.balanceOf(address(ksxVault)), depositAmount); + assertEq(depositToken.balanceOf(alice), initialBalance - depositAmount); - vm.startPrank(alice); - depositToken.approve(address(ksxVault), depositTokenNeeded); - ksxVault.mint(sharesToMint, alice); + // Redeem all shares + ksxVault.redeem(sharesMinted, alice, alice); - // Verify we got the correct number of shares - assertEq(ksxVault.balanceOf(alice), sharesToMint); + // Verify final state + assertEq(ksxVault.balanceOf(alice), 0); + assertEq(stakingRewards.balanceOf(address(ksxVault)), 0); + assertEq(depositToken.balanceOf(alice), initialBalance); - assertEq( - depositToken.balanceOf(alice), - initialDepositTokenBalance - depositTokenNeeded - ); vm.stopPrank(); } - // Withdraws a specified amount of assets from the vault by burning the - // equivalent shares - function test_withdraw() public { - uint256 amount = 1 ether; + function test_vault_redeem_1000_ratio() public { + // Setup: Alice deposits 1 KWENTA first + uint256 depositAmount = 1 ether; + uint256 expectedShares = 1000 ether; // 1000 KSX for 1 KWENTA vm.startPrank(alice); - // Approve and deposit - depositToken.approve(address(ksxVault), amount); - ksxVault.deposit(amount, alice); + // Initial deposit + depositToken.approve(address(ksxVault), depositAmount); + ksxVault.deposit(depositAmount, alice); // Verify initial state - assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); - assertEq(stakingRewards.balanceOf(address(ksxVault)), amount); + assertEq(ksxVault.balanceOf(alice), expectedShares); + assertEq(stakingRewards.balanceOf(address(ksxVault)), depositAmount); + + // Record balance before redeem + uint256 preBalance = depositToken.balanceOf(alice); - // Withdraw - ksxVault.withdraw(amount, alice, alice); + // Redeem all shares + uint256 assetsReceived = ksxVault.redeem(expectedShares, alice, alice); // Verify final state + assertEq(assetsReceived, depositAmount); assertEq(ksxVault.balanceOf(alice), 0); assertEq(stakingRewards.balanceOf(address(ksxVault)), 0); - assertEq(depositToken.balanceOf(alice), 10 ether); + assertEq(depositToken.balanceOf(alice), preBalance + depositAmount); vm.stopPrank(); } - function test_redeem() public { - uint256 amount = 1 ether; + function test_vault_redeem_with_rewards() public { + uint256 depositAmount = 1 ether; + uint256 rewardsAmount = 0.5 ether; + + // Initial Deposit vm.startPrank(alice); - depositToken.approve(address(ksxVault), amount); - ksxVault.mint(1 ether, alice); - assertEq(stakingRewards.balanceOf(address(ksxVault)), amount / 1000); - assertEq( - stakingRewards.balanceOf(address(ksxVault)), - amount / (10 ** ksxVault.offset()) - ); + uint256 initialBalance = depositToken.balanceOf(alice); + depositToken.approve(address(ksxVault), depositAmount); + uint256 aliceShares = ksxVault.deposit(depositAmount, alice); + + // Verify initial state - Initial shares should be 1000 KSX + assertEq(aliceShares, 1000 ether); + assertEq(ksxVault.totalAssets(), depositAmount); + assertEq(stakingRewards.balanceOf(address(ksxVault)), depositAmount); + + // Add Rewards + vm.stopPrank(); + depositToken.mint(address(stakingRewards), rewardsAmount); + stakingRewards.addRewardToStaker(address(ksxVault), rewardsAmount); + + // Verify rewards were added + assertEq(ksxVault.totalAssets(), depositAmount + rewardsAmount); + + vm.startPrank(alice); + + // First redemption (redeem 2/3 of shares ~ 1 KWENTA initial value) + uint256 sharesToRedeem = (aliceShares * 2) / 3; // 666.666... KSX + uint256 firstRedeemAssets = + ksxVault.redeem(sharesToRedeem, alice, alice); + + // ~ 1.5 KWENTA in the vault so 2/3 of shares should be ~ 1 KWENTA + assertApproxEqAbs(firstRedeemAssets, 1 ether, 1); + + // Alice Should have 1/3 of shares remaining + assertEq(ksxVault.balanceOf(alice), aliceShares - sharesToRedeem); + + // Should have ~0.5 KWENTA remaining in the vault + assertApproxEqAbs(ksxVault.totalAssets(), 0.5 ether, 1); + + // Redeem remaining shares + uint256 remainingShares = ksxVault.balanceOf(alice); + uint256 secondRedeemAssets = + ksxVault.redeem(remainingShares, alice, alice); - ksxVault.redeem(amount, alice, alice); assertEq(ksxVault.balanceOf(alice), 0); - // assertEq(stakingRewards.stakedBalanceOf(address(ksxVault)), 0); - // assertEq(depositToken.balanceOf(alice), 10 ether); + // "Should have at most 1 wei remaining due to rounding" + assertLe(stakingRewards.balanceOf(address(ksxVault)), 1); + + // Verify total assets received + uint256 totalReceived = firstRedeemAssets + secondRedeemAssets; + assertApproxEqAbs( + totalReceived, + depositAmount + rewardsAmount, + 2 // Allow 2 wei tolerance for cumulative rounding + ); + + // Verify final balance + assertApproxEqAbs( + depositToken.balanceOf(alice), + initialBalance - depositAmount + totalReceived, + 2 + ); + + vm.stopPrank(); + } + + function test_vault_redeem_no_shares() public { + vm.startPrank(alice); + vm.expectRevert( + abi.encodeWithSelector( + ERC4626ExceededMaxRedeem.selector, alice, 1 ether, 0 + ) + ); + ksxVault.redeem(1 ether, alice, alice); + vm.stopPrank(); + } + + function test_vault_redeem_more_than_balance() public { + // Setup: Alice deposits 1 KWENTA + uint256 depositAmount = 1 ether; + + vm.startPrank(alice); + depositToken.approve(address(ksxVault), depositAmount); + ksxVault.deposit(depositAmount, alice); + + // Try to redeem more shares than owned + // Alice has 1000 KSX (1000 ether) but tries to redeem 2000 KSX (2000 + // ether) + vm.expectRevert( + abi.encodeWithSelector( + ERC4626ExceededMaxRedeem.selector, alice, 2000 ether, 1000 ether + ) + ); + ksxVault.redeem(2000 ether, alice, alice); vm.stopPrank(); } From dd40f5faff703dbbdd75864266c062b15ba4f985 Mon Sep 17 00:00:00 2001 From: Flocqst Date: Tue, 29 Oct 2024 17:39:15 +0100 Subject: [PATCH 20/20] =?UTF-8?q?=F0=9F=93=9A=20nit:=20cleanup=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/KSXVault.t.sol | 36 ++++++++++++++---------------------- test/utils/Constants.sol | 4 ++++ 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/test/KSXVault.t.sol b/test/KSXVault.t.sol index f57942f..0ade4fc 100644 --- a/test/KSXVault.t.sol +++ b/test/KSXVault.t.sol @@ -29,12 +29,6 @@ contract KSXVaultTest is Bootstrap { depositToken.mint(alice, 10 ether); depositToken.mint(bob, 10 ether); - // vm.prank(alice); - // depositToken.approve(address(ksxVault), type(uint256).max); - - // vm.prank(bob); - // depositToken.approve(address(ksxVault), type(uint256).max); - // Give infinite approval to the staking rewards contract for the vault vm.prank(address(ksxVault)); depositToken.approve(address(stakingRewards), type(uint256).max); @@ -50,10 +44,10 @@ contract KSXVaultTest is Bootstrap { // asserts deposited kwenta is correctly staked in the staking rewards // contract function test_vault_deposit() public { - uint256 amount = 1 ether; + uint256 amount = ONE_KWENTA; vm.startPrank(alice); depositToken.approve(address(ksxVault), amount); - ksxVault.deposit(1 ether, alice); + ksxVault.deposit(amount, alice); assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); assertEq(stakingRewards.balanceOf(address(ksxVault)), amount); vm.stopPrank(); @@ -61,7 +55,7 @@ contract KSXVaultTest is Bootstrap { // Asserts correct deposit at 1000 shares ratio function test_vault_deposit_1000_ratio() public { - uint256 depositAmount = 1 ether; + uint256 depositAmount = ONE_KWENTA; uint256 expectedShares = 1000 ether; // We expect 1000x the deposit // amount @@ -93,8 +87,7 @@ contract KSXVaultTest is Bootstrap { // Asserts the 1:1000 ratio is maintained when no rewards have accrued function test_vault_deposits() public { - // First deposit: 1 KWENTA - uint256 firstDeposit = 1 ether; + uint256 firstDeposit = ONE_KWENTA; vm.startPrank(alice); @@ -106,8 +99,7 @@ contract KSXVaultTest is Bootstrap { assertEq(firstShares, 1000 ether); assertEq(stakingRewards.balanceOf(address(ksxVault)), firstDeposit); - // Second deposit: 2 KWENTA - uint256 secondDeposit = 2 ether; + uint256 secondDeposit = 2 * ONE_KWENTA; depositToken.approve(address(ksxVault), secondDeposit); uint256 secondShares = ksxVault.deposit(secondDeposit, alice); @@ -120,15 +112,15 @@ contract KSXVaultTest is Bootstrap { // Verify final state assertEq(ksxVault.balanceOf(alice), 3000 ether); - assertEq(stakingRewards.balanceOf(address(ksxVault)), 3 ether); + assertEq(stakingRewards.balanceOf(address(ksxVault)), 3 * ONE_KWENTA); vm.stopPrank(); } function test_vault_deposits_with_rewards_between() public { // Initial setup - uint256 initialDeposit = 1 ether; - uint256 rewardsAmount = 0.5 ether; + uint256 initialDeposit = ONE_KWENTA; + uint256 rewardsAmount = REWARDS; // First deposit from Alice vm.startPrank(alice); @@ -151,7 +143,7 @@ contract KSXVaultTest is Bootstrap { ); // Bob's deposit - uint256 bobDeposit = 1 ether; + uint256 bobDeposit = ONE_KWENTA; // Calculate expected shares for Bob // At this point, ratio is no longer 1:1000 due to rewards: @@ -215,7 +207,7 @@ contract KSXVaultTest is Bootstrap { } function test_vault_redeem() public { - uint256 depositAmount = 1 ether; + uint256 depositAmount = ONE_KWENTA; vm.startPrank(alice); uint256 initialBalance = depositToken.balanceOf(alice); @@ -242,7 +234,7 @@ contract KSXVaultTest is Bootstrap { function test_vault_redeem_1000_ratio() public { // Setup: Alice deposits 1 KWENTA first - uint256 depositAmount = 1 ether; + uint256 depositAmount = ONE_KWENTA; uint256 expectedShares = 1000 ether; // 1000 KSX for 1 KWENTA vm.startPrank(alice); @@ -271,8 +263,8 @@ contract KSXVaultTest is Bootstrap { } function test_vault_redeem_with_rewards() public { - uint256 depositAmount = 1 ether; - uint256 rewardsAmount = 0.5 ether; + uint256 depositAmount = ONE_KWENTA; + uint256 rewardsAmount = REWARDS; // Initial Deposit vm.startPrank(alice); @@ -301,7 +293,7 @@ contract KSXVaultTest is Bootstrap { ksxVault.redeem(sharesToRedeem, alice, alice); // ~ 1.5 KWENTA in the vault so 2/3 of shares should be ~ 1 KWENTA - assertApproxEqAbs(firstRedeemAssets, 1 ether, 1); + assertApproxEqAbs(firstRedeemAssets, ONE_KWENTA, 1); // Alice Should have 1/3 of shares remaining assertEq(ksxVault.balanceOf(alice), aliceShares - sharesToRedeem); diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol index 491b149..f9f1421 100644 --- a/test/utils/Constants.sol +++ b/test/utils/Constants.sol @@ -15,4 +15,8 @@ contract Constants { uint8 public constant DECIMAL_OFFSET = 3; + uint256 internal constant ONE_KWENTA = 1 ether; + + uint256 internal constant REWARDS = 0.5 ether; + }