From ddb821be58928c32a10e7d85993a419c91b65872 Mon Sep 17 00:00:00 2001 From: FirelightDev Date: Tue, 15 Jul 2025 19:09:24 -0300 Subject: [PATCH] Fix 4626 max methods implementation --- contracts/FirelightVault.sol | 71 +++++++++++++++++++++++++++++++++++- test/blocklist.js | 16 +++++++- test/deposit_and_withdraw.js | 59 ++++++++++++++++++++++++++++-- test/pause.js | 12 ++++++ 4 files changed, 151 insertions(+), 7 deletions(-) diff --git a/contracts/FirelightVault.sol b/contracts/FirelightVault.sol index 2aaa494..5fb2f09 100644 --- a/contracts/FirelightVault.sol +++ b/contracts/FirelightVault.sol @@ -16,7 +16,18 @@ import {Checkpoints} from "./lib/Checkpoints.sol"; /** * @title FirelightVault - * @notice Upgradeable ERC4626-compatible vault + * @notice Upgradeable ERC4626-compatible vault with delayed withdrawals. + * + * @dev FirelightVault is an ERC4626 vault that intentionally deviates from the standard. + * It overrides `withdraw` and `redeem` to implement delayed withdrawals. + * Instead of transferring assets immediately, these functions create a withdrawal request, + * which must be completed later via `claimWithdraw` after a set delay. + * + * The standard `Withdraw` event is not emitted. Instead, `WithdrawRequest` and `CompleteWithdraw` + * are used to track the withdrawal process. + * + * Off-chain and on-chain tools must account for this custom flow and event structure. + * * @custom:security-contact securityreport@firelight.finance */ contract FirelightVault is @@ -292,6 +303,64 @@ contract FirelightVault is return currentEnd + periodConfigurationAtTimestamp(currentEnd).duration; } + /** + * @notice Returns the maximum amount of the underlying asset that can be deposited into the Vault for the receiver, + * through a deposit call. + * @param receiver The address of the deposit receiver. + * @return amount Maximum amount of assets that can be deposited. + */ + function maxDeposit(address receiver) public view override returns (uint256 amount) { + uint256 assets = totalAssets(); + if (isBlocklisted[receiver] || paused() || assets > depositLimit) { + return 0; + } else { + return depositLimit - assets; + } + } + + /** + * @notice Returns the maximum amount of the Vault shares that can be minted for the receiver, through a mint call. + * @param receiver The address of the mint receiver. + * @return amount Maximum amount of shares that can be minted. + */ + function maxMint(address receiver) public view override returns (uint256 amount) { + uint256 shares = totalSupply(); + uint256 sharesLimit = convertToShares(depositLimit); + if (isBlocklisted[receiver] || paused() || shares > sharesLimit) { + return 0; + } else { + return sharesLimit - shares; + } + } + + /** + * @notice Returns the maximum amount of the underlying asset that can be withdrawn from the owner balance in the + * Vault, through a withdraw call. + * @param owner The owner of the assets. + * @return amount Maximum amount of assets that can be withdrawn. + */ + function maxWithdraw(address owner) public view override returns (uint256 amount) { + if (isBlocklisted[owner] || paused()) { + return 0; + } else { + return _convertToAssets(balanceOf(owner), Math.Rounding.Floor); + } + } + + /** + * @notice Returns the maximum amount of Vault shares that can be redeemed from the owner balance in the Vault, + * through a redeem call. + * @param owner The owner of the shares. + * @param amount Maximum amount of shares that can be redeemed. + */ + function maxRedeem(address owner) public view override returns (uint256 amount) { + if (isBlocklisted[owner] || paused()) { + return 0; + } else { + return balanceOf(owner); + } + } + /** * @notice Returns the total assets in the vault excluding those marked for withdrawal. * @return The total assets held by the vault. diff --git a/test/blocklist.js b/test/blocklist.js index b5539b5..0f2ec90 100644 --- a/test/blocklist.js +++ b/test/blocklist.js @@ -35,6 +35,18 @@ describe('Blocklist test', function() { expect(status).to.equal(true) }) + it('returns correct values for maxDeposit, maxMint, maxWithdraw and maxRedeem if the user is blocklisted', async () => { + const max_deposit = await firelight_vault.maxDeposit(users[0].address), + max_mint = await firelight_vault.maxMint(users[0].address), + max_withdraw = await firelight_vault.maxWithdraw(users[0].address), + max_redeem = await firelight_vault.maxRedeem(users[0].address) + + expect(max_deposit).to.be.equal(0n) + expect(max_mint).to.be.equal(0n) + expect(max_withdraw).to.be.equal(0n) + expect(max_redeem).to.be.equal(0n) + }) + it('reverts if blocklister tries to blocklist a user that is already blocklisted', async () => { const blocklist = firelight_vault.connect(blocklister).addToBlocklist(users[0].address) await expect(blocklist).to.be.revertedWithCustomError(firelight_vault, 'BlocklistedAddress') @@ -86,12 +98,12 @@ describe('Blocklist test', function() { await expect(deposit_attempt).to.be.revertedWithCustomError(firelight_vault, 'BlocklistedAddress') }) - it('reverts if a user attempts to redem from a blocklisted user', async () => { + it('reverts if a user attempts to redeem from a blocklisted user', async () => { const redeem_attempt = firelight_vault.connect(users[1]).redeem(DEPOSIT_AMOUNT, users[1].address, users[0].address) await expect(redeem_attempt).to.be.revertedWithCustomError(firelight_vault, 'BlocklistedAddress') }) - it('reverts if a user attempts to redem to a blocklisted user', async () => { + it('reverts if a user attempts to redeem to a blocklisted user', async () => { const redeem_attempt = firelight_vault.connect(users[1]).redeem(DEPOSIT_AMOUNT, users[0].address, users[2].address) await expect(redeem_attempt).to.be.revertedWithCustomError(firelight_vault, 'BlocklistedAddress') }) diff --git a/test/deposit_and_withdraw.js b/test/deposit_and_withdraw.js index eb15213..b2eebad 100644 --- a/test/deposit_and_withdraw.js +++ b/test/deposit_and_withdraw.js @@ -35,24 +35,40 @@ describe('Deposit and Withdraw test', function() { expect(deposit_limit.toString()).to.equal(TARGET_DEPOSIT_LIMIT) }) + it('returns correct values for maxDeposit and maxMint', async () => { + const max_deposit = await firelight_vault.maxDeposit(users[0].address), + shares_preview = await firelight_vault.previewDeposit(max_deposit), + max_mint = await firelight_vault.maxMint(users[0].address) + + expect(max_deposit).to.be.equal(TARGET_DEPOSIT_LIMIT) + expect(max_mint).to.be.equal(shares_preview) + }) + it('deposits tokens and receives the expected amount of shares', async () => { const shares_preview = await firelight_vault.previewDeposit(DEPOSIT_AMOUNT), - depositTrx = firelight_vault.connect(users[0]).deposit(DEPOSIT_AMOUNT, users[0]) + deposit_tx = firelight_vault.connect(users[0]).deposit(DEPOSIT_AMOUNT, users[0]) - await expect(depositTrx).to.emit(firelight_vault, 'Deposit').withArgs( + await expect(deposit_tx).to.emit(firelight_vault, 'Deposit').withArgs( users[0].address, users[0].address, DEPOSIT_AMOUNT, shares_preview ) const shares = await firelight_vault.balanceOf(users[0].address) expect(shares.toString()).to.equal(DEPOSIT_AMOUNT) + + const max_deposit = await firelight_vault.maxDeposit(users[0].address), + max_deposit_shares = await firelight_vault.previewDeposit(max_deposit), + max_mint = await firelight_vault.maxMint(users[0].address) + + expect(max_deposit).to.be.equal(TARGET_DEPOSIT_LIMIT - DEPOSIT_AMOUNT) + expect(max_mint).to.be.equal(max_deposit_shares) }) it('mints shares and deducts the expected amount of tokens', async () => { const prev_token_bal = await token_contract.balanceOf(users[1]), assets_preview = await firelight_vault.previewMint(DEPOSIT_AMOUNT), - mintedTrx = firelight_vault.connect(users[1]).mint(DEPOSIT_AMOUNT, users[1]) + mint_tx = firelight_vault.connect(users[1]).mint(DEPOSIT_AMOUNT, users[1]) - await expect(mintedTrx).to.emit(firelight_vault, 'Deposit').withArgs( + await expect(mint_tx).to.emit(firelight_vault, 'Deposit').withArgs( users[1].address, users[1].address, assets_preview, DEPOSIT_AMOUNT ) @@ -61,6 +77,13 @@ describe('Deposit and Withdraw test', function() { const assets = await token_contract.balanceOf(users[1].address) expect(assets).to.equal(prev_token_bal - DEPOSIT_AMOUNT) + + const max_deposit = await firelight_vault.maxDeposit(users[0].address), + max_deposit_shares = await firelight_vault.previewDeposit(max_deposit), + max_mint = await firelight_vault.maxMint(users[0].address) + + expect(max_deposit).to.be.equal(TARGET_DEPOSIT_LIMIT - DEPOSIT_AMOUNT * 2n) + expect(max_mint).to.be.equal(max_deposit_shares) }) it('reverts when user tries to request withdraw with more than what it owns', async () => { @@ -73,12 +96,27 @@ describe('Deposit and Withdraw test', function() { await expect(withdraw_request).to.be.revertedWithCustomError(firelight_vault, 'InsufficientShares') }) + it('returns correct values for maxWithdraw and maxRedeem', async () => { + const max_withdraw = await firelight_vault.connect(users[0]).maxWithdraw(users[0].address), + max_withdraw_shares = await firelight_vault.connect(users[0]).previewWithdraw(max_withdraw), + max_redeem = await firelight_vault.connect(users[0]).maxRedeem(users[0].address) + + expect(max_withdraw).to.be.equal(DEPOSIT_AMOUNT) + expect(max_redeem).to.be.equal(max_withdraw_shares) + }) + it('reverts when trying to complete the withdraw before the next period', async() => { const receipt = await (await firelight_vault.connect(users[0]).withdraw(DEPOSIT_AMOUNT, users[0].address, users[0].address)).wait() withdraw_period = receipt.logs[1].args[3] const withdraw_attempt = firelight_vault.connect(users[0]).claimWithdraw(withdraw_period) await expect(withdraw_attempt).to.be.revertedWithCustomError(firelight_vault, 'InvalidPeriod') + + const max_withdraw = await firelight_vault.connect(users[0]).maxWithdraw(users[0].address), + max_redeem = await firelight_vault.connect(users[0]).maxRedeem(users[0].address) + + expect(max_withdraw).to.be.equal(0n) + expect(max_redeem).to.be.equal(0n) }) it('reads the user\'s pending withdrawals', async () => { @@ -112,4 +150,17 @@ describe('Deposit and Withdraw test', function() { const complete_withdraw = firelight_vault.connect(users[0]).claimWithdraw(withdraw_period + 1n) await expect(complete_withdraw).to.be.revertedWithCustomError(firelight_vault, 'NoWithdrawalAmount') }) + + it('decreases the deposit limit below total value', async () => { + await firelight_vault.connect(limit_updater).updateDepositLimit(INITIAL_DEPOSIT_LIMIT) + + const deposit_limit = await firelight_vault.depositLimit() + expect(deposit_limit.toString()).to.equal(INITIAL_DEPOSIT_LIMIT) + + const max_deposit = await firelight_vault.maxDeposit(users[0].address), + max_mint = await firelight_vault.maxMint(users[0].address) + + expect(max_deposit).to.be.equal(0n) + expect(max_mint).to.be.equal(0n) + }) }) \ No newline at end of file diff --git a/test/pause.js b/test/pause.js index e5bc57c..4155628 100644 --- a/test/pause.js +++ b/test/pause.js @@ -32,6 +32,18 @@ describe('Pause test', function() { expect(await firelight_vault.paused()).to.equal(true) }) + it('returns correct values for maxDeposit, maxMint, maxWithdraw and maxRedeem when the contract is paused', async () => { + const max_deposit = await firelight_vault.maxDeposit(users[0].address), + max_mint = await firelight_vault.maxMint(users[0].address), + max_withdraw = await firelight_vault.maxWithdraw(users[0].address), + max_redeem = await firelight_vault.maxRedeem(users[0].address) + + expect(max_deposit).to.be.equal(0n) + expect(max_mint).to.be.equal(0n) + expect(max_withdraw).to.be.equal(0n) + expect(max_redeem).to.be.equal(0n) + }) + it('reverts when trying to deposit if the contract is paused', async () => { const deposit_attempt = firelight_vault.connect(users[0]).deposit(DEPOSIT_AMOUNT / 2n, users[0].address) await expect(deposit_attempt).to.be.revertedWithCustomError(firelight_vault, 'EnforcedPause')