diff --git a/.gitmodules b/.gitmodules index 690924b..3970c70 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,13 @@ [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 +[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/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}" 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 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 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/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 f8e746c..7c770f0 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,81 +1,41 @@ // 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); - Setup.deploySystem(); - - vm.stopBroadcast(); + function deploySystem( + address token, + address stakingRewards, + uint8 decimalOffset + ) + public + returns (KSXVault ksxVault) + { + ksxVault = new KSXVault(token, stakingRewards, decimalOffset); + + // deploy ERC1967 proxy and set implementation to ksxVault + Proxy proxy = new Proxy(address(ksxVault), ""); + + // "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; + +} diff --git a/src/KSXVault.sol b/src/KSXVault.sol index 4993b24..9578118 100644 --- a/src/KSXVault.sol +++ b/src/KSXVault.sol @@ -1,16 +1,181 @@ // 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 {SafeERC20} from + "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IStakingRewardsV2} from "@token/interfaces/IStakingRewardsV2.sol"; -/// @title Kwenta Example Contract +/// @title KSXVault Contract /// @notice KSX ERC4626 Vault /// @author Flocqst (florian@kwenta.io) contract KSXVault is ERC4626 { - constructor(address _token) + + /*////////////////////////////////////////////////////////////// + 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 + /// further protect against inflation attacks + /// (https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack) + uint8 public immutable offset; + + /// @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 + //////////////////////////////////////////////////////////////*/ + + /// @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); + 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; + } + + /// @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 + /// @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 shares = super.deposit(assets, receiver); + _collectAndStakeRewards(); + return shares; + } + + /// @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) + { + revert("Disabled"); + } + + /// @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, + address owner, + uint256 assets, + uint256 shares + ) + internal + virtual + override + { + // First check and burn shares + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + _burn(owner, shares); + + // Then unstake corresponding assets + _unstakeKWENTA(assets); + + // Finally transfer assets + SafeERC20.safeTransfer(KWENTA, receiver, assets); + + emit Withdraw(caller, receiver, owner, assets, shares); + } + + /*////////////////////////////////////////////////////////////// + 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/test/KSXVault.t.sol b/test/KSXVault.t.sol index a980305..0ade4fc 100644 --- a/test/KSXVault.t.sol +++ b/test/KSXVault.t.sol @@ -1,20 +1,362 @@ // 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"; -contract KSXVaultTest is Test { - function setUp() public {} -} +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 { + + 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)); + initializeLocal( + address(depositToken), address(stakingRewards), DECIMAL_OFFSET + ); + + depositToken.mint(alice, 10 ether); + depositToken.mint(bob, 10 ether); + + // 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 + 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 + // asserts deposited kwenta is correctly staked in the staking rewards + // contract + function test_vault_deposit() public { + uint256 amount = ONE_KWENTA; + vm.startPrank(alice); + depositToken.approve(address(ksxVault), amount); + ksxVault.deposit(amount, alice); + assertEq(ksxVault.balanceOf(alice), amount * (10 ** ksxVault.offset())); + assertEq(stakingRewards.balanceOf(address(ksxVault)), amount); + vm.stopPrank(); + } + + // Asserts correct deposit at 1000 shares ratio + function test_vault_deposit_1000_ratio() public { + uint256 depositAmount = ONE_KWENTA; + 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); -contract MockERC20 is ERC20 { - constructor(string memory name_, string memory symbol_) - ERC20(name_, symbol_) - {} + vm.stopPrank(); + } + + // Asserts the 1:1000 ratio is maintained when no rewards have accrued + function test_vault_deposits() public { + uint256 firstDeposit = ONE_KWENTA; + + vm.startPrank(alice); + + // First deposit + depositToken.approve(address(ksxVault), firstDeposit); + uint256 firstShares = ksxVault.deposit(firstDeposit, alice); + + // Verify first deposit results + assertEq(firstShares, 1000 ether); + assertEq(stakingRewards.balanceOf(address(ksxVault)), firstDeposit); + + uint256 secondDeposit = 2 * ONE_KWENTA; + depositToken.approve(address(ksxVault), secondDeposit); + uint256 secondShares = ksxVault.deposit(secondDeposit, alice); - function mint(address to, uint256 amount) external { - _mint(to, amount); + // Verify second deposit results + assertEq(secondShares, 2000 ether); + assertEq( + stakingRewards.balanceOf(address(ksxVault)), + firstDeposit + secondDeposit + ); + + // Verify final state + assertEq(ksxVault.balanceOf(alice), 3000 ether); + assertEq(stakingRewards.balanceOf(address(ksxVault)), 3 * ONE_KWENTA); + + vm.stopPrank(); } + + function test_vault_deposits_with_rewards_between() public { + // Initial setup + uint256 initialDeposit = ONE_KWENTA; + uint256 rewardsAmount = REWARDS; + + // First deposit from Alice + vm.startPrank(alice); + 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)), + initialDeposit + rewardsAmount + ); + + // Bob's deposit + uint256 bobDeposit = ONE_KWENTA; + + // 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); + } + + function test_vault_deposit_more_than_balance() public { + uint256 aliceBalance = 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); + vm.expectRevert("Disabled"); + ksxVault.mint(1000 ether, alice); + vm.stopPrank(); + } + + // Test that withdraw is disabled + function test_withdraw_disabled() public { + vm.startPrank(alice); + vm.expectRevert("Disabled"); + ksxVault.withdraw(1 ether, alice, alice); + vm.stopPrank(); + } + + function test_vault_redeem() public { + uint256 depositAmount = ONE_KWENTA; + vm.startPrank(alice); + + uint256 initialBalance = depositToken.balanceOf(alice); + + // Deposit + depositToken.approve(address(ksxVault), depositAmount); + uint256 sharesMinted = ksxVault.deposit(depositAmount, alice); + + // Verify initial state + assertEq(sharesMinted, depositAmount * (10 ** ksxVault.offset())); + assertEq(stakingRewards.balanceOf(address(ksxVault)), depositAmount); + assertEq(depositToken.balanceOf(alice), initialBalance - depositAmount); + + // Redeem all shares + ksxVault.redeem(sharesMinted, alice, alice); + + // Verify final state + assertEq(ksxVault.balanceOf(alice), 0); + assertEq(stakingRewards.balanceOf(address(ksxVault)), 0); + assertEq(depositToken.balanceOf(alice), initialBalance); + + vm.stopPrank(); + } + + function test_vault_redeem_1000_ratio() public { + // Setup: Alice deposits 1 KWENTA first + uint256 depositAmount = ONE_KWENTA; + uint256 expectedShares = 1000 ether; // 1000 KSX for 1 KWENTA + + vm.startPrank(alice); + + // Initial deposit + depositToken.approve(address(ksxVault), depositAmount); + ksxVault.deposit(depositAmount, alice); + + // Verify initial state + assertEq(ksxVault.balanceOf(alice), expectedShares); + assertEq(stakingRewards.balanceOf(address(ksxVault)), depositAmount); + + // Record balance before redeem + uint256 preBalance = depositToken.balanceOf(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), preBalance + depositAmount); + + vm.stopPrank(); + } + + function test_vault_redeem_with_rewards() public { + uint256 depositAmount = ONE_KWENTA; + uint256 rewardsAmount = REWARDS; + + // Initial Deposit + vm.startPrank(alice); + 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, ONE_KWENTA, 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); + + assertEq(ksxVault.balanceOf(alice), 0); + // "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(); + } + } 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..ce72f5d --- /dev/null +++ b/test/mocks/MockStakingRewards.sol @@ -0,0 +1,59 @@ +// 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 balanceOf(address account) external view returns (uint256) { + 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; + } + +} diff --git a/test/utils/Bootstrap.sol b/test/utils/Bootstrap.sol index 9d264d1..d063e6b 100644 --- a/test/utils/Bootstrap.sol +++ b/test/utils/Bootstrap.sol @@ -1,69 +1,70 @@ // 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 {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 { - 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, Constants { -contract Bootstrap is Test { using console2 for *; - Counter internal counter; + // decimal offset + uint256 public decimalsOffset; - function initializeLocal() internal { - BootstrapLocal bootstrap = new BootstrapLocal(); - (address counterAddress) = bootstrap.init(); + // deployed contracts + KSXVault internal ksxVault; - counter = Counter(counterAddress); - } + IERC20 public TOKEN; - function initializeOptimismGoerli() internal { - BootstrapOptimismGoerli bootstrap = new BootstrapOptimismGoerli(); - (address counterAddress) = bootstrap.init(); + IStakingRewardsV2 public STAKING_REWARDS; - 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, + address _stakingRewards, + uint8 _decimalsOffset + ) + internal + { + BootstrapLocal bootstrap = new BootstrapLocal(); + (address ksxVaultAddress) = + bootstrap.init(_token, _stakingRewards, _decimalsOffset); - counter = Counter(counterAddress); + decimalsOffset = _decimalsOffset; + TOKEN = IERC20(_token); + STAKING_REWARDS = IStakingRewardsV2(_stakingRewards); + 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, + address _stakingRewards, + uint8 _decimalsOffset + ) + public + returns (address) + { + (KSXVault ksxvault) = + Setup.deploySystem(_token, _stakingRewards, _decimalsOffset); + + return (address(ksxvault)); } -} - -contract BootstrapOptimism is Setup, OptimismParameters { - function init() public returns (address) { - address counterAddress = Setup.deploySystem(); - - return counterAddress; - } -} - -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..f9f1421 --- /dev/null +++ b/test/utils/Constants.sol @@ -0,0 +1,22 @@ +// 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; + + uint256 internal constant ONE_KWENTA = 1 ether; + + uint256 internal constant REWARDS = 0.5 ether; + +}