// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import "forge-std/Test.sol"; import { VaultfireBIPVaultV3 } from "../../contracts/vault/VaultfireBIPVaultV3.sol"; import { FeeOnTransferMockERC20 } from "../mocks/FeeOnTransferMockERC20.sol"; import { MockERC20 } from "../mocks/MockERC20.sol"; import { OutboundFeeMockERC20 } from "../mocks/MaliciousERC20s.sol"; /// @notice ERC-4626 accounting, rounding, and donation-safety properties. contract VaultfireBIPVaultV3ERC4626Test is Test { uint256 private constant VIRTUAL_SHARES = 1e6; address private constant PROTOCOL_OWNER = address(0x0B0B); address private constant ALICE = address(0xA11CE); address private constant BOB = address(0xB0B); address private constant ATTACKER = address(0xA77AC); address private constant VICTIM = address(0xB1C71); address private constant DONOR = address(0xD0D0); MockERC20 private assetToken; VaultfireBIPVaultV3 private vault; function setUp() public { assetToken = new MockERC20(); vault = new VaultfireBIPVaultV3(address(assetToken), PROTOCOL_OWNER, type(uint256).max); } // --------------------------------------------------------------------- // ERC-4626 deposit / withdraw / mint / redeem accounting // --------------------------------------------------------------------- function testFuzz_depositMintsExactlyPreviewedSharesAndTransfersAssets(uint96 rawAssets) external { uint256 assets = bound(uint256(rawAssets), 1, 1e24); _fundAndApprove(ALICE, assets); uint256 expectedShares = vault.previewDeposit(assets); uint256 aliceAssetsBefore = assetToken.balanceOf(ALICE); vm.prank(ALICE); uint256 mintedShares = vault.deposit(assets, ALICE); assertEq(mintedShares, expectedShares, "deposit differs from previewDeposit"); assertEq(vault.balanceOf(ALICE), expectedShares, "receiver ERC-20 share balance incorrect"); assertEq(vault.shareOf(ALICE), expectedShares, "legacy share alias incorrect"); assertEq(vault.totalSupply(), expectedShares, "ERC-20 share supply incorrect"); assertEq(vault.totalShares(), expectedShares, "legacy share supply alias incorrect"); assertEq(assetToken.balanceOf(ALICE), aliceAssetsBefore - assets, "depositor asset debit incorrect"); assertEq(vault.totalAssets(), assets, "vault asset accounting incorrect"); } function testFuzz_roundTripDepositThenSelfWithdrawReturnsAllAssets(uint96 rawAssets) external { uint256 assets = bound(uint256(rawAssets), 1, 1e24); _fundAndApprove(ALICE, assets); uint256 aliceAssetsBefore = assetToken.balanceOf(ALICE); vm.startPrank(ALICE); uint256 mintedShares = vault.deposit(assets, ALICE); uint256 burnedShares = vault.withdraw(assets, ALICE, ALICE); vm.stopPrank(); assertEq(burnedShares, mintedShares, "same-state round trip must burn minted shares"); assertEq(vault.balanceOf(ALICE), 0, "round trip left user shares"); assertEq(vault.totalSupply(), 0, "round trip left total shares"); assertEq(vault.totalAssets(), 0, "round trip left vault assets"); assertEq(assetToken.balanceOf(ALICE), aliceAssetsBefore, "round trip lost assets"); } function testFuzz_roundTripMintThenSelfRedeemReturnsAllAssets(uint96 rawAssetUnits) external { uint256 shares = bound(uint256(rawAssetUnits), 1, 1e24) * VIRTUAL_SHARES; uint256 assetsForMint = vault.previewMint(shares); _fundAndApprove(ALICE, assetsForMint); vm.startPrank(ALICE); uint256 paidAssets = vault.mint(shares, ALICE); uint256 expectedRedeemAssets = vault.previewRedeem(shares); uint256 redeemedAssets = vault.redeem(shares, ALICE, ALICE); vm.stopPrank(); assertEq(paidAssets, assetsForMint, "mint differs from previewMint"); assertEq(redeemedAssets, expectedRedeemAssets, "redeem differs from previewRedeem"); assertEq(redeemedAssets, assetsForMint, "same-state mint/redeem must round trip"); assertEq(vault.balanceOf(ALICE), 0, "redeem left user shares"); assertEq(vault.totalAssets(), 0, "redeem left vault assets"); } function testFuzz_allPreviewsUseRequiredRoundingDirections(uint96 rawDonation, uint96 rawProbe) external { _fundAndApprove(ALICE, 17); vm.prank(ALICE); vault.deposit(17, ALICE); uint256 donation = bound(uint256(rawDonation), 1, 1e24); uint256 probe = bound(uint256(rawProbe), 1, 1e24); assetToken.mint(DONOR, donation); vm.prank(DONOR); assertTrue(assetToken.transfer(address(vault), donation)); uint256 sharesNumerator = probe * (vault.totalSupply() + VIRTUAL_SHARES); uint256 assetsNumerator = probe * (vault.totalAssets() + 1); uint256 shareDenominator = vault.totalAssets() + 1; uint256 assetDenominator = vault.totalSupply() + VIRTUAL_SHARES; uint256 expectedSharesFloor = sharesNumerator / shareDenominator; uint256 expectedSharesCeil = (sharesNumerator + shareDenominator - 1) / shareDenominator; uint256 expectedAssetsFloor = assetsNumerator / assetDenominator; uint256 expectedAssetsCeil = (assetsNumerator + assetDenominator - 1) / assetDenominator; assertEq(vault.convertToShares(probe), expectedSharesFloor, "convertToShares must round down"); assertEq(vault.previewDeposit(probe), expectedSharesFloor, "previewDeposit must round down"); assertEq(vault.previewWithdraw(probe), expectedSharesCeil, "previewWithdraw must round up"); assertEq(vault.convertToAssets(probe), expectedAssetsFloor, "convertToAssets must round down"); assertEq(vault.previewRedeem(probe), expectedAssetsFloor, "previewRedeem must round down"); assertEq(vault.previewMint(probe), expectedAssetsCeil, "previewMint must round up"); } function testFuzz_totalSharesReconcileAndOutstandingSharesAreSolvent( uint96 rawAliceAssets, uint96 rawBobAssets, uint96 rawDonation ) external { uint256 aliceAssets = bound(uint256(rawAliceAssets), 1, 1e24); uint256 bobAssets = bound(uint256(rawBobAssets), 1, 1e24); uint256 donation = bound(uint256(rawDonation), 0, 1e24); _fundAndApprove(ALICE, aliceAssets); _fundAndApprove(BOB, bobAssets); vm.prank(ALICE); vault.deposit(aliceAssets, ALICE); vm.prank(BOB); vault.deposit(bobAssets, BOB); if (donation != 0) { assetToken.mint(DONOR, donation); vm.prank(DONOR); assertTrue(assetToken.transfer(address(vault), donation)); } uint256 accountedShares = vault.balanceOf(ALICE) + vault.balanceOf(BOB); assertEq(vault.totalSupply(), accountedShares, "share ledger does not reconcile"); assertEq(vault.totalShares(), accountedShares, "legacy totalShares alias does not reconcile"); assertEq(assetToken.balanceOf(address(vault)), vault.totalAssets(), "totalAssets does not match asset balance"); assertLe( vault.convertToAssets(vault.totalSupply()), vault.totalAssets(), "outstanding shares claim more assets than the vault holds" ); } // --------------------------------------------------------------------- // ERC-20 share surface, allowances, and non-custodial access // --------------------------------------------------------------------- function test_erc4626SurfaceAndMaxFunctions() external { assertEq(vault.asset(), address(assetToken), "wrong asset"); assertEq(vault.maxDeposit(ALICE), type(uint256).max, "unexpected deposit cap"); assertEq(vault.maxMint(ALICE), type(uint256).max, "unexpected mint cap"); assertEq(vault.maxWithdraw(ALICE), 0, "empty account can withdraw"); assertEq(vault.maxRedeem(ALICE), 0, "empty account can redeem"); _fundAndApprove(ALICE, 100); vm.prank(ALICE); uint256 mintedShares = vault.deposit(100, ALICE); assertEq(vault.maxRedeem(ALICE), mintedShares, "maxRedeem must equal balance"); assertEq(vault.maxWithdraw(ALICE), vault.convertToAssets(mintedShares), "maxWithdraw incorrect"); assertGt(vault.previewMint(VIRTUAL_SHARES), 0, "previewMint must be functional"); assertGt(vault.previewRedeem(mintedShares), 0, "previewRedeem must be functional"); } function test_shareTransfersAndDelegatedWithdrawSpendAllowance() external { _fundAndApprove(ALICE, 100); vm.prank(ALICE); vault.deposit(100, ALICE); vm.prank(ALICE); assertTrue(vault.approve(BOB, 3)); vm.prank(ALICE); assertTrue(vault.transfer(BOB, 1)); vm.prank(BOB); assertTrue(vault.transferFrom(ALICE, BOB, 2)); assertEq(vault.balanceOf(BOB), 3, "share transfers did not credit delegate"); assertEq(vault.allowance(ALICE, BOB), 1, "transferFrom did not spend allowance"); uint256 assets = 10; uint256 sharesForWithdrawal = vault.previewWithdraw(assets); vm.prank(ALICE); assertTrue(vault.approve(BOB, sharesForWithdrawal)); uint256 bobAssetsBefore = assetToken.balanceOf(BOB); vm.prank(BOB); uint256 burnedShares = vault.withdraw(assets, BOB, ALICE); assertEq(burnedShares, sharesForWithdrawal, "delegated withdrawal differs from preview"); assertEq(assetToken.balanceOf(BOB), bobAssetsBefore + assets, "delegated withdrawal sent wrong assets"); assertEq(vault.allowance(ALICE, BOB), 0, "withdraw did not spend allowance"); } function test_unauthorizedUserAndProtocolOwnerCannotWithdrawDepositorAssets() external { _fundAndApprove(ALICE, 100); vm.prank(ALICE); vault.deposit(100, ALICE); vm.prank(BOB); vm.expectRevert(); vault.withdraw(1, BOB, ALICE); // The ownership role never grants a claim on depositor assets. vm.prank(PROTOCOL_OWNER); vm.expectRevert(); vault.withdraw(1, PROTOCOL_OWNER, ALICE); } function test_failedAssetPullCannotCreateFreeShares() external { assetToken.mint(ALICE, 100); uint256 expectedShares = vault.previewDeposit(100); vm.prank(ALICE); vm.expectRevert(MockERC20.InsufficientAllowance.selector); vault.deposit(100, ALICE); assertEq(vault.balanceOf(ALICE), 0, "failed pull minted user shares"); assertEq(vault.totalSupply(), 0, "failed pull changed total shares"); assertEq(vault.totalAssets(), 0, "failed pull changed assets"); assertGt(expectedShares, 0, "test precondition: valid deposit would mint shares"); } function test_feeOnTransferAssetCannotCreateUnbackedShares() external { FeeOnTransferMockERC20 feeAsset = new FeeOnTransferMockERC20(); VaultfireBIPVaultV3 feeVault = new VaultfireBIPVaultV3(address(feeAsset), PROTOCOL_OWNER, type(uint256).max); feeAsset.mint(ALICE, 100); vm.startPrank(ALICE); feeAsset.approve(address(feeVault), 100); vm.expectRevert(VaultfireBIPVaultV3.UnsupportedAssetTransfer.selector); feeVault.deposit(100, ALICE); vm.stopPrank(); assertEq(feeVault.balanceOf(ALICE), 0, "fee asset deposit minted unbacked shares"); assertEq(feeVault.totalSupply(), 0, "fee asset deposit changed total supply"); assertEq(feeAsset.balanceOf(address(feeVault)), 0, "fee asset deposit left assets after revert"); assertEq(feeAsset.balanceOf(ALICE), 100, "fee asset deposit charged depositor after revert"); } function test_zeroValueActionsAreRejected() external { vm.startPrank(ALICE); vm.expectRevert(VaultfireBIPVaultV3.ZeroAmount.selector); vault.deposit(0, ALICE); vm.expectRevert(VaultfireBIPVaultV3.ZeroAmount.selector); vault.mint(0, ALICE); vm.expectRevert(VaultfireBIPVaultV3.ZeroAmount.selector); vault.withdraw(0, ALICE, ALICE); vm.expectRevert(VaultfireBIPVaultV3.ZeroAmount.selector); vault.redeem(0, ALICE, ALICE); vm.stopPrank(); } function test_immutableAssetCapDrivesMaxDepositAndMaxMint() external { VaultfireBIPVaultV3 capped = new VaultfireBIPVaultV3(address(assetToken), PROTOCOL_OWNER, 100); assertEq(capped.totalAssetCap(), 100); assertEq(capped.maxDeposit(ALICE), 100); assertEq(capped.maxMint(ALICE), capped.previewDeposit(100)); assetToken.mint(ALICE, 101); vm.startPrank(ALICE); assetToken.approve(address(capped), type(uint256).max); capped.deposit(100, ALICE); vm.expectRevert(); capped.deposit(1, ALICE); vm.stopPrank(); assertEq(capped.maxDeposit(ALICE), 0); assertEq(capped.maxMint(ALICE), 0); } function test_feeOnOutboundTransferCannotBurnSharesWithoutExactAssetDelivery() external { OutboundFeeMockERC20 outboundFee = new OutboundFeeMockERC20(); VaultfireBIPVaultV3 guarded = new VaultfireBIPVaultV3(address(outboundFee), PROTOCOL_OWNER, 100); outboundFee.mint(ALICE, 10); vm.startPrank(ALICE); outboundFee.approve(address(guarded), 10); guarded.deposit(10, ALICE); vm.expectRevert(VaultfireBIPVaultV3.UnsupportedAssetTransfer.selector); guarded.withdraw(10, ALICE, ALICE); vm.stopPrank(); assertGt(guarded.balanceOf(ALICE), 0); assertEq(guarded.totalAssets(), 10); } // --------------------------------------------------------------------- // Direct-donation / forced ERC-20 transfer safety // --------------------------------------------------------------------- function testFuzz_directDonationMintsNoSharesAndCannotProfitTheFirstDepositor(uint96 rawDonation) external { uint256 donation = bound(uint256(rawDonation), 1, 1e24); _fundAndApprove(ATTACKER, 1); vm.prank(ATTACKER); uint256 attackerShares = vault.deposit(1, ATTACKER); assetToken.mint(DONOR, donation); vm.prank(DONOR); assertTrue(assetToken.transfer(address(vault), donation)); assertEq(vault.totalSupply(), attackerShares, "direct transfer minted shares"); assertEq(vault.balanceOf(DONOR), 0, "direct transfer credited donor shares"); assertEq(vault.totalAssets(), donation + 1, "direct transfer not reflected in assets"); assertLe( vault.convertToAssets(attackerShares), donation + 1, "first depositor can profit from a direct-donation inflation attack" ); } function testFuzz_followerReceivesSharesAfterFirstDepositorDonation(uint96 rawDonation) external { uint256 donation = bound(uint256(rawDonation), 1, 1e24); _fundAndApprove(ATTACKER, 1); vm.prank(ATTACKER); vault.deposit(1, ATTACKER); assetToken.mint(ATTACKER, donation); vm.prank(ATTACKER); assertTrue(assetToken.transfer(address(vault), donation)); uint256 followerAssets = donation + 2; _fundAndApprove(VICTIM, followerAssets); uint256 preview = vault.previewDeposit(followerAssets); assertGt(preview, 0, "test precondition: follower should receive shares"); vm.prank(VICTIM); uint256 mintedShares = vault.deposit(followerAssets, VICTIM); assertEq(mintedShares, preview, "follower deposit differs from preview"); assertEq(vault.balanceOf(VICTIM), mintedShares, "follower was not credited shares"); assertEq(vault.balanceOf(ATTACKER), VIRTUAL_SHARES, "donation changed attacker shares"); } function test_donationInflationCannotPullAssetsForZeroShares() external { _fundAndApprove(ATTACKER, 1); vm.prank(ATTACKER); vault.deposit(1, ATTACKER); // This makes previewDeposit(1) round down to zero without the guard. assetToken.mint(ATTACKER, 2 * VIRTUAL_SHARES); vm.prank(ATTACKER); assertTrue(assetToken.transfer(address(vault), 2 * VIRTUAL_SHARES)); _fundAndApprove(VICTIM, 1); uint256 victimAssetsBefore = assetToken.balanceOf(VICTIM); uint256 victimSharesBefore = vault.balanceOf(VICTIM); vm.prank(VICTIM); vm.expectRevert(VaultfireBIPVaultV3.ZeroShares.selector); vault.deposit(1, VICTIM); assertEq(assetToken.balanceOf(VICTIM), victimAssetsBefore, "zero-share deposit pulled a victim asset"); assertEq(vault.balanceOf(VICTIM), victimSharesBefore, "zero-share deposit minted shares"); assertEq(vault.totalAssets(), 2 * VIRTUAL_SHARES + 1, "failed deposit changed vault assets"); } function _fundAndApprove(address account, uint256 assets) private { assetToken.mint(account, assets); vm.prank(account); assetToken.approve(address(vault), type(uint256).max); } }