diff --git a/src/FixedFeeSwap.sol b/src/FixedFeeSwap.sol index a9033bb..6b2eb00 100644 --- a/src/FixedFeeSwap.sol +++ b/src/FixedFeeSwap.sol @@ -32,10 +32,10 @@ contract FixedFeeSwap { /// @notice Thrown when deposit is attempted with an invalid amount. error FixedFeeSwap_InvalidAmount(); - /// @notice Thrown when split is attempted with an invalid maturity. + /// @notice Thrown when the maturity is in the past. error FixedFeeSwap_InvalidMaturity(); - /// @notice Thrown when split is attempted with an invalid vault share token. + /// @notice Thrown when the vault share token is zero. error FixedFeeSwap_InvalidVaultShareToken(); /// @notice Thrown when action is attempted with an insufficient balance. diff --git a/src/FixedFeeSwapMarket.sol b/src/FixedFeeSwapMarket.sol index 93383e3..c491ce4 100644 --- a/src/FixedFeeSwapMarket.sol +++ b/src/FixedFeeSwapMarket.sol @@ -396,6 +396,44 @@ contract FixedFeeSwapMarket is BaseHook { emit FeesCollected(_positionId, _recipient, _amount0, _amount1); } + /// @notice Returns the current market price, computing from tick if not yet initialized + /// @return The current price in WAD precision + function getCurrentPrice() external view returns (int256) { + return currentPrice != 0 ? currentPrice : tickToPrice(currentTick); + } + + /// @notice Swaps tokens directly without going through PoolManager + /// @param _amountIn The amount of input tokens + /// @param _principalForCash True to swap PT→cash, false to swap cash→PT + /// @param _tickLimit Tick limit (0 = no limit) + /// @return amountInUsed The amount of input tokens consumed + /// @return amountOut The amount of output tokens received + function swap(uint256 _amountIn, bool _principalForCash, int24 _tickLimit) + external + returns (uint256 amountInUsed, uint256 amountOut) + { + if (_amountIn == 0) revert FixedFeeSwapMarket_SwapAmountCannotBeZero(); + if (block.timestamp >= MATURITY) revert FixedFeeSwapMarket_MarketExpired(); + if (activeLiquidity == 0) revert FixedFeeSwapMarket_NoActiveLiquidity(); + currentPrice = tickToPrice(currentTick); + + int24 _effectiveTickLimit = _tickLimit == 0 ? NO_TICK_LIMIT : _tickLimit; + (amountInUsed, amountOut) = _executeSwapLoop(_amountIn, _principalForCash, _effectiveTickLimit); + + // Execute token transfers + if (_principalForCash) { + // User sends principal (TOKEN0), receives cash (TOKEN1) + if (amountInUsed != 0) TOKEN0.safeTransferFrom(msg.sender, address(this), amountInUsed); + if (amountOut != 0) TOKEN1.safeTransfer(msg.sender, amountOut); + } else { + // User sends cash (TOKEN1), receives principal (TOKEN0) + if (amountInUsed != 0) TOKEN1.safeTransferFrom(msg.sender, address(this), amountInUsed); + if (amountOut != 0) TOKEN0.safeTransfer(msg.sender, amountOut); + } + + emit Swap(msg.sender, _principalForCash, amountInUsed, amountOut); + } + // ============ Internal Functions - Hook Overrides ============ /// @notice Hook called before liquidity is added via PoolManager diff --git a/src/Zapper.sol b/src/Zapper.sol index 239f984..593db00 100644 --- a/src/Zapper.sol +++ b/src/Zapper.sol @@ -7,6 +7,8 @@ import {YieldToken} from "src/YieldToken.sol"; import {VaultShareToken} from "src/VaultShareToken.sol"; import {FixedFeeSwap} from "src/FixedFeeSwap.sol"; import {FeeTracking} from "src/lib/FeeTracking.sol"; +import {FixedFeeSwapMarket} from "src/FixedFeeSwapMarket.sol"; + // External Imports import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -70,16 +72,23 @@ contract Zapper is IERC3156FlashBorrower { /// @notice The fixed fee swap hook address. FixedFeeSwap public immutable FIXED_FEE_SWAP; + /// @notice The fixed fee swap market address. + FixedFeeSwapMarket public immutable FIXED_FEE_SWAP_MARKET; + /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ /// @notice Initializes the zapper. /// @param _fixedFeeSwap The fixed fee swap address. - constructor(FixedFeeSwap _fixedFeeSwap) { - if (address(_fixedFeeSwap) == address(0)) revert Zapper_InvalidAddress(); + /// @param _fixedFeeSwapMarket The fixed fee swap market address. + constructor(FixedFeeSwap _fixedFeeSwap, FixedFeeSwapMarket _fixedFeeSwapMarket) { + if (address(_fixedFeeSwap) == address(0) || address(_fixedFeeSwapMarket) == address(0)) { + revert Zapper_InvalidAddress(); + } FIXED_FEE_SWAP = _fixedFeeSwap; + FIXED_FEE_SWAP_MARKET = _fixedFeeSwapMarket; YIELD_TOKEN = _fixedFeeSwap.YIELD_TOKEN(); PRINCIPAL_TOKEN = _fixedFeeSwap.PRINCIPAL_TOKEN(); VAULT_SHARE_TOKEN = _fixedFeeSwap.VAULT_SHARE_TOKEN(); @@ -105,10 +114,14 @@ contract Zapper is IERC3156FlashBorrower { "" // No data needed ); - // 3. After repaying flash loan (via `onFlashLoan`), transfer remaining VST to user + // 3. After repaying flash loan (via `onFlashLoan`), transfer remaining tokens to user uint256 _vstReceived = VAULT_SHARE_TOKEN.balanceOf(address(this)); VAULT_SHARE_TOKEN.transfer(msg.sender, _vstReceived); + // Transfer any remaining PT dust to user (from rounding in VST→PT swap) + uint256 _ptRemaining = PRINCIPAL_TOKEN.balanceOf(address(this)); + if (_ptRemaining > 0) IERC20(address(PRINCIPAL_TOKEN)).transfer(msg.sender, _ptRemaining); + emit YieldTokenSold(msg.sender, _ytAmount, _vstReceived); } @@ -121,7 +134,9 @@ contract Zapper is IERC3156FlashBorrower { VAULT_SHARE_TOKEN.transferFrom(msg.sender, address(this), _vstAmount); // 2. Calculate PT debt: X = vstAmount / (1 - ptPrice) - uint256 _ptPrice = 0.98e18; // Mock: 0.98 VST per PT (18 decimals) + // Get current PT price from market (WAD-scaled, e.g., 0.98e18 = 0.98 VST per PT) + int256 _ptPriceSigned = FIXED_FEE_SWAP_MARKET.getCurrentPrice(); + uint256 _ptPrice = _ptPriceSigned > 0 ? uint256(_ptPriceSigned) : 0; uint256 _ptDebt = Math.mulDiv(_vstAmount, FeeTracking.PRECISION, FeeTracking.PRECISION - _ptPrice); @@ -182,10 +197,16 @@ contract Zapper is IERC3156FlashBorrower { IERC20(address(YIELD_TOKEN)).approve(address(FIXED_FEE_SWAP), _amount); // Burn PT and YT to mint VST - uint256 _vstMinted = FIXED_FEE_SWAP.redeem(_amount); + FIXED_FEE_SWAP.redeem(_amount); + + // Calculate VST needed to buy back _amount PT for flash loan repayment. + // At price p: to get X PT out, need X * p / WAD VST in (since output = input * WAD / p). + int256 _ptPriceSigned = FIXED_FEE_SWAP_MARKET.getCurrentPrice(); + uint256 _ptPrice = _ptPriceSigned > 0 ? uint256(_ptPriceSigned) : 0; + uint256 _vstNeeded = Math.mulDiv(_amount, _ptPrice, FeeTracking.PRECISION, Math.Rounding.Ceil); - // Calculate and swap portion of VST for PT to repay flash loan - uint256 _swappedPT = _calculateAndSwapVSTForPT(_vstMinted, _amount); + // Swap only the needed VST for PT to repay flash loan + uint256 _swappedPT = _swapVSTForPT(_vstNeeded); // Approve PT contract to spend PT to repay flash loan IERC20(address(PRINCIPAL_TOKEN)).approve(address(PRINCIPAL_TOKEN), _swappedPT); @@ -194,11 +215,16 @@ contract Zapper is IERC3156FlashBorrower { /// @notice Handles flash loan callback for buying YT. /// @param _amount The amount of PT to repay (flash loan amount). function _handleBuyFlashLoan(uint256 _amount) internal { - // Mock: Swap PT for VST (assume 1 PT = 0.98 VST) - // In production, this would be an AMM swap - uint256 _vstFromSwap = (_amount * 98) / 100; + // Swap PT for VST (cash) using the market + // principalForCash = true means: PT in → cash out + IERC20(address(PRINCIPAL_TOKEN)).approve(address(FIXED_FEE_SWAP_MARKET), _amount); + FIXED_FEE_SWAP_MARKET.swap( + _amount, // amountIn (PT) + true, // principalForCash = true (we're swapping principal FOR cash) + 0 // no price limit + ); - // Combine swapped VST with user's VST + // Combine swapped VST with user's existing VST uint256 _totalVst = VAULT_SHARE_TOKEN.balanceOf(address(this)); // Split all VST into PT + YT @@ -211,31 +237,22 @@ contract Zapper is IERC3156FlashBorrower { // YT stays in contract and will be sent to user after callback } - /// @notice Calculates and swaps portion of available VST for PT to repay flash loan. - /// @param _availableVST The amount of VST available to swap. - /// @param _neededPt The amount of PT needed to repay flash loan. - /// @return _swappedPT The amount of PT obtained from swapping VST. - /// @dev Mock implementation: uses deposit() with assumption that 1 VST = 0.5 PT + 0.5 YT. - /// Since deposit() gives 1 VST = 1 PT + 1 YT, we swap 2x the needed PT to account for this. - /// This ensures we always leave some VST for the user. In production, use AMM curve. - function _calculateAndSwapVSTForPT(uint256 _availableVST, uint256 _neededPt) - internal - returns (uint256 _swappedPT) - { - // Mock: Assume we need 2 VST to get 1 PT (since deposit gives 1 VST = 1 PT + 1 YT, - // but we only need PT, so we effectively need 2 VST per 1 PT) - uint256 _vstToSwap = _neededPt * 2; - - // Cap at available VST - if (_vstToSwap > _availableVST) _vstToSwap = _availableVST; - - // Use deposit to swap VST for PT+YT (mock: 1 VST = 1 PT + 1 YT) - IERC20(address(VAULT_SHARE_TOKEN)).approve(address(FIXED_FEE_SWAP), _vstToSwap); - FIXED_FEE_SWAP.deposit(_vstToSwap); - // When we split VST, we split it into equal amounts of YT + PT (1:1 ratio) - _swappedPT = _vstToSwap; - // YT amount is unused but left in contract - - // Note: This mock always leaves VST for the user since we swap 2x the needed PT + /// @notice Swaps VST (cash) for PT using the FixedFeeSwapMarket + /// @param _availableVST The amount of VST available to swap + /// @return _swappedPT The amount of PT obtained from swapping VST + function _swapVSTForPT(uint256 _availableVST) internal returns (uint256 _swappedPT) { + // Approve market to spend VST (cash token) + IERC20(address(VAULT_SHARE_TOKEN)).approve(address(FIXED_FEE_SWAP_MARKET), _availableVST); + + // Swap VST (cash) for PT (principal) + // principalForCash = false means: cash in → PT out + (, _swappedPT) = FIXED_FEE_SWAP_MARKET.swap( + _availableVST, // amountIn (VST) + false, // principalForCash = false (we're swapping cash FOR principal) + 0 // no price limit + ); + + // Note: If we get less PT than needed, the flash loan repayment will fail + // The caller should ensure sufficient VST is available for the swap } } diff --git a/test/FixedFeeSwapMarket.t.sol b/test/FixedFeeSwapMarket.t.sol index 290f22d..7ae255e 100644 --- a/test/FixedFeeSwapMarket.t.sol +++ b/test/FixedFeeSwapMarket.t.sol @@ -451,7 +451,7 @@ abstract contract FixedFeeSwapMarketTest is Test { } function _initializePrice() internal returns (int256) { - if (hook.getCurrentPrice() != 0) return hook.getCurrentPrice(); + if (hook.getRawCurrentPrice() != 0) return hook.getCurrentPrice(); _swap(bob, 1, true, hook.getCurrentTick()); @@ -3882,6 +3882,99 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { vm.prank(_caller); feeHook.collectFees(_positionId, _caller, 0, 0); } + + // ============ Direct Swap Tests (FixedFeeSwapMarket.swap) ============ + + function testFuzz_DirectSwapPrincipalForCashTransfersCorrectTokens(uint256 _amount) public { + // Minimum 2 bips (2 * activeLiquidity / BIPS) for guaranteed tick movement + _amount = bound(_amount, _ptForOneBip() * 2, 1e20); + + uint256 _token0Before = token0.balanceOf(bob); + uint256 _token1Before = token1.balanceOf(bob); + + vm.prank(bob); + (uint256 _amountInUsed, uint256 _amountOut) = hook.swap(_amount, true, 0); + + assertGt(_amountInUsed, 0, "Should consume input"); + assertGt(_amountOut, 0, "Should produce output"); + assertEq(token0.balanceOf(bob), _token0Before - _amountInUsed, "PT should decrease by amountIn"); + assertEq(token1.balanceOf(bob), _token1Before + _amountOut, "Cash should increase by amountOut"); + } + + function testFuzz_DirectSwapCashForPrincipalTransfersCorrectTokens(uint256 _amount) public { + // Minimum 2 bips for guaranteed tick movement + _amount = bound(_amount, _ptForOneBip() * 2, 1e20); + + uint256 _token0Before = token0.balanceOf(bob); + uint256 _token1Before = token1.balanceOf(bob); + + vm.prank(bob); + (uint256 _amountInUsed, uint256 _amountOut) = hook.swap(_amount, false, 0); + + assertGt(_amountInUsed, 0, "Should consume input"); + assertGt(_amountOut, 0, "Should produce output"); + assertEq( + token1.balanceOf(bob), _token1Before - _amountInUsed, "Cash should decrease by amountIn" + ); + assertEq(token0.balanceOf(bob), _token0Before + _amountOut, "PT should increase by amountOut"); + } + + function testFuzz_DirectSwapEmitsSwapEvent(uint256 _amount) public { + _amount = bound(_amount, _ptForOneBip() * 2, 1e20); + + // Check indexed sender; skip data fields (amounts unknown before call) + vm.expectEmit(true, false, false, false, address(hook)); + emit FixedFeeSwapMarket.Swap(bob, true, 0, 0); + + vm.prank(bob); + hook.swap(_amount, true, 0); + } + + function testFuzz_DirectSwapDoesNotConsumeMoreThanRequested(uint256 _amount) public { + _amount = bound(_amount, _ptForOneBip() * 2, 1e20); + + vm.prank(bob); + (uint256 _amountInUsed, uint256 _amountOut) = hook.swap(_amount, true, 0); + + assertGt(_amountInUsed, 0, "Direct swap should consume input"); + assertGt(_amountOut, 0, "Direct swap should produce output"); + assertLe(_amountInUsed, _amount, "Should not consume more than requested"); + } + + function testFuzz_RevertIf_DirectSwapAmountIsZero(bool _principalForCash) public { + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_SwapAmountCannotBeZero.selector); + hook.swap(0, _principalForCash, 0); + } + + function testFuzz_RevertIf_DirectSwapMarketExpired(uint256 _amount, bool _principalForCash) + public + { + _amount = bound(_amount, 1, 1e20); + vm.warp(hook.getStartTime() + MARKET_LENGTH); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_MarketExpired.selector); + hook.swap(_amount, _principalForCash, 0); + } + + function testFuzz_RevertIf_DirectSwapNoActiveLiquidity(uint256 _amount, bool _principalForCash) + public + { + _amount = bound(_amount, 1, 1e20); + // Deploy fresh hook with no liquidity + address _mockManager = makeAddr("directSwapNoLiqManager"); + FixedFeeSwapMarketHarness _freshHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + vm.warp(_freshHook.getStartTime() + 1); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_NoActiveLiquidity.selector); + vm.prank(alice); + _freshHook.swap(_amount, _principalForCash, 0); + } } contract _BeforeAddLiquidity is FixedFeeSwapMarketTest { @@ -4202,3 +4295,4 @@ contract LiquidityGap is FixedFeeSwapMarketTest { _swap(bob, int256(_cashInToBoundary + 1), false, NO_TICK_LIMIT); } } + diff --git a/test/Zapper.unit.t.sol b/test/Zapper.unit.t.sol index 878107a..1a22d41 100644 --- a/test/Zapper.unit.t.sol +++ b/test/Zapper.unit.t.sol @@ -3,12 +3,13 @@ pragma solidity ^0.8.26; // External imports import {Test} from "forge-std/Test.sol"; -import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // Internal imports import {Zapper} from "src/Zapper.sol"; import {FixedFeeSwap} from "src/FixedFeeSwap.sol"; +import {FixedFeeSwapMarket} from "src/FixedFeeSwapMarket.sol"; +import {FixedFeeSwapMarketMock} from "test/mock/FixedFeeSwapMarketMock.sol"; import {VaultShareToken} from "src/VaultShareToken.sol"; import {PrincipalToken} from "src/PrincipalToken.sol"; import {YieldToken} from "src/YieldToken.sol"; @@ -17,10 +18,10 @@ import {ERC20Mock} from "test/mock/ERC20Mock.sol"; contract ZapperTest is Test { Zapper internal zapper; FixedFeeSwap internal fixedFeeSwap; + FixedFeeSwapMarketMock internal fixedFeeSwapMarketMock; VaultShareToken internal vaultShareToken; ERC20Mock internal token0; ERC20Mock internal token1; - IPoolManager internal poolManager; address internal user; uint256 internal maturity; @@ -37,15 +38,24 @@ contract ZapperTest is Test { vaultShareToken = new VaultShareToken("Vault Share Token", "VST", address(token0), address(token1)); - // PoolManager is only stored in the hook; we never call into it in these tests - poolManager = IPoolManager(makeAddr("poolManager")); - // Choose a maturity strictly in the future maturity = block.timestamp + 30 days; fixedFeeSwap = new FixedFeeSwap(vaultShareToken, maturity); - zapper = new Zapper(fixedFeeSwap); + // Create mock FixedFeeSwapMarket for PT/VST trading + PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); + fixedFeeSwapMarketMock = new FixedFeeSwapMarketMock( + address(_pt), // token0 = PT (principal) + address(vaultShareToken) // token1 = VST (cash) + ); + + // Fund the mock market with tokens so it can perform swaps + // Use a large but reasonable amount to cover all test scenarios + deal(address(_pt), address(fixedFeeSwapMarketMock), 1e30); + deal(address(vaultShareToken), address(fixedFeeSwapMarketMock), 1e30); + + zapper = new Zapper(fixedFeeSwap, FixedFeeSwapMarket(address(fixedFeeSwapMarketMock))); user = makeAddr("user"); } @@ -55,7 +65,17 @@ contract ZapperTest is Test { } function _boundToRealisticAmount(uint256 _amount) internal pure returns (uint256) { - return bound(_amount, 1, type(uint128).max); + // Use a smaller max to avoid overflow issues in price calculations + return bound(_amount, 1, 1e24); // Max 1 million tokens with 18 decimals + } + + /// @notice Bounds and sets the mock price on the market mock. + /// @param _price Raw fuzz input. + /// @return The bounded price in WAD. + function _boundAndSetMockPrice(uint256 _price) internal returns (uint256) { + _price = bound(_price, 0.01e18, 0.99e18); + fixedFeeSwapMarketMock.setMockPrice(int256(_price)); + return _price; } /// @notice Helper function to deposit VST for a user and get PT and YT tokens. @@ -89,6 +109,7 @@ contract ZapperTest is Test { contract Constructor is ZapperTest { function test_SetsAllParamsCorrectly() public view { assertEq(address(zapper.FIXED_FEE_SWAP()), address(fixedFeeSwap)); + assertEq(address(zapper.FIXED_FEE_SWAP_MARKET()), address(fixedFeeSwapMarketMock)); assertEq(address(zapper.YIELD_TOKEN()), address(fixedFeeSwap.YIELD_TOKEN())); assertEq(address(zapper.PRINCIPAL_TOKEN()), address(fixedFeeSwap.PRINCIPAL_TOKEN())); assertEq(address(zapper.VAULT_SHARE_TOKEN()), address(fixedFeeSwap.VAULT_SHARE_TOKEN())); @@ -96,13 +117,21 @@ contract Constructor is ZapperTest { function test_RevertIf_FixedFeeSwapIsZero() public { vm.expectRevert(Zapper.Zapper_InvalidAddress.selector); - new Zapper(FixedFeeSwap(payable(address(0)))); + new Zapper( + FixedFeeSwap(payable(address(0))), FixedFeeSwapMarket(address(fixedFeeSwapMarketMock)) + ); + } + + function test_RevertIf_FixedFeeSwapMarketIsZero() public { + vm.expectRevert(Zapper.Zapper_InvalidAddress.selector); + new Zapper(fixedFeeSwap, FixedFeeSwapMarket(address(0))); } } contract SellYieldToken is ZapperTest { - function testFuzz_TransfersYTFromUserToZapper(uint256 _ytAmount) public { + function testFuzz_TransfersYTFromUserToZapper(uint256 _ytAmount, uint256 _mockPrice) public { _ytAmount = _boundToRealisticAmount(_ytAmount); + _boundAndSetMockPrice(_mockPrice); // Deposit VST to get PT and YT for the user _depositVstForUser(user, _ytAmount); @@ -129,8 +158,10 @@ contract SellYieldToken is ZapperTest { assertEq(_userYTBefore - _userYTAfter, _ytAmount, "YT balance should decrease by exact amount"); } - function testFuzz_UserReceivesVSTAfterSell(uint256 _ytAmount) public { - _ytAmount = _boundToRealisticAmount(_ytAmount); + function testFuzz_UserReceivesVSTAfterSell(uint256 _ytAmount, uint256 _mockPrice) public { + // Minimum 1e18 so profit survives integer rounding at any price + _ytAmount = bound(_ytAmount, 1e18, 1e24); + _mockPrice = _boundAndSetMockPrice(_mockPrice); // Deposit VST to get PT and YT for the user _depositVstForUser(user, _ytAmount); @@ -150,19 +181,24 @@ contract SellYieldToken is ZapperTest { vm.prank(user); zapper.sellYieldToken(_ytAmount); - // User should receive VST (amount depends on mock swap implementation) + // User should receive VST (amount depends on price) uint256 _userVSTAfter = vaultShareToken.balanceOf(user); uint256 _vstReceived = _userVSTAfter - _userVSTBefore; - // With mock implementation: redeem gives _ytAmount VST, then swap 2*_ytAmount VST for PT - // Since deposit gives 1:1 PT:YT, and we need _ytAmount PT, we swap _ytAmount*2 VST - // But capped at available VST, so user receives: _ytAmount - min(_ytAmount*2, _ytAmount) + - // swappedPT_extraYT The exact amount varies by implementation, but should be non-negative - assertGe(_vstReceived, 0, "VST received should be non-negative"); + // Redeem gives _ytAmount VST, swap ceil(_ytAmount * price / WAD) VST for PT. + // User receives the discount: ~_ytAmount * (1 - price) / WAD, minus up to 1 wei rounding. + assertGt(_vstReceived, 0, "User must receive non-zero VST from sell"); + + uint256 _expectedProfit = (_ytAmount * (1e18 - _mockPrice)) / 1e18; + // Ceil rounding on vstNeeded can reduce profit by 1 wei + assertGe(_vstReceived + 1, _expectedProfit, "VST received should be close to expected profit"); } - function testFuzz_ZapperHasNoRemainingBalancesAfterSell(uint256 _ytAmount) public { + function testFuzz_ZapperHasNoRemainingBalancesAfterSell(uint256 _ytAmount, uint256 _mockPrice) + public + { _ytAmount = _boundToRealisticAmount(_ytAmount); + _boundAndSetMockPrice(_mockPrice); // Deposit VST to get PT and YT for the user _depositVstForUser(user, _ytAmount); @@ -186,14 +222,18 @@ contract SellYieldToken is ZapperTest { "Zapper should have no remaining VST after sell" ); - // Zapper should have no remaining YT (may have some from swap but transferred to user) - // Note: Due to mock implementation, zapper may retain some YT from the deposit step - // In production with AMM, this would be handled differently + // Zapper should have no remaining YT + assertEq(_yt.balanceOf(address(zapper)), 0, "Zapper should have no remaining YT after sell"); + + // Zapper should have no remaining PT + PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); + assertEq(_pt.balanceOf(address(zapper)), 0, "Zapper should have no remaining PT after sell"); } - function testFuzz_EmitsYieldTokenSoldEvent(uint256 _ytAmount) public { - _ytAmount = _boundToRealisticAmount(_ytAmount); - vm.assume(_ytAmount > 1); + function testFuzz_EmitsYieldTokenSoldEvent(uint256 _ytAmount, uint256 _mockPrice) public { + // Minimum 1e18 so profit survives integer rounding at any price + _ytAmount = bound(_ytAmount, 1e18, 1e24); + _boundAndSetMockPrice(_mockPrice); // Deposit VST to get PT and YT for the user _depositVstForUser(user, _ytAmount); @@ -206,16 +246,17 @@ contract SellYieldToken is ZapperTest { vm.prank(user); _yt.approve(address(zapper), _ytAmount); - // Expect event with correct user and ytAmount (vstReceived is checked separately) - vm.expectEmit(true, false, false, false); - emit Zapper.YieldTokenSold(user, _ytAmount, 0); + // Check indexed user; skip data fields (vstReceived unknown before call) + vm.expectEmit(true, false, false, false, address(zapper)); + emit Zapper.YieldTokenSold(user, 0, 0); vm.prank(user); zapper.sellYieldToken(_ytAmount); } - function testFuzz_FlashLoanIsRepaidSuccessfully(uint256 _ytAmount) public { + function testFuzz_FlashLoanIsRepaidSuccessfully(uint256 _ytAmount, uint256 _mockPrice) public { _ytAmount = _boundToRealisticAmount(_ytAmount); + _boundAndSetMockPrice(_mockPrice); // Deposit VST to get PT and YT for the user _depositVstForUser(user, _ytAmount); @@ -243,8 +284,9 @@ contract SellYieldToken is ZapperTest { } contract BuyYieldToken is ZapperTest { - function testFuzz_TransfersVSTFromUserToZapper(uint256 _vstAmount) public { + function testFuzz_TransfersVSTFromUserToZapper(uint256 _vstAmount, uint256 _mockPrice) public { _vstAmount = _boundToRealisticAmount(_vstAmount); + _boundAndSetMockPrice(_mockPrice); // Give user VST deal(address(vaultShareToken), user, _vstAmount); @@ -269,8 +311,9 @@ contract BuyYieldToken is ZapperTest { ); } - function testFuzz_UserReceivesYTAfterBuy(uint256 _vstAmount) public { + function testFuzz_UserReceivesYTAfterBuy(uint256 _vstAmount, uint256 _mockPrice) public { _vstAmount = _boundToRealisticAmount(_vstAmount); + _boundAndSetMockPrice(_mockPrice); // Give user VST deal(address(vaultShareToken), user, _vstAmount); @@ -301,11 +344,14 @@ contract BuyYieldToken is ZapperTest { // Exact amount depends on mock implementation } - function testFuzz_YTReceivedIsProportionalToVSTSupplied(uint256 _vstAmount1, uint256 _vstAmount2) - public - { + function testFuzz_YTReceivedIsProportionalToVSTSupplied( + uint256 _vstAmount1, + uint256 _vstAmount2, + uint256 _mockPrice + ) public { _vstAmount1 = bound(_vstAmount1, 1e18, type(uint64).max); _vstAmount2 = bound(_vstAmount2, 1e18, type(uint64).max); + _boundAndSetMockPrice(_mockPrice); vm.assume(_vstAmount1 != _vstAmount2); address _user1 = makeAddr("user1"); @@ -338,8 +384,11 @@ contract BuyYieldToken is ZapperTest { else assertGt(_yt2, _yt1, "More VST should yield more YT"); } - function testFuzz_ZapperHasNoRemainingBalancesAfterBuy(uint256 _vstAmount) public { + function testFuzz_ZapperHasNoRemainingBalancesAfterBuy(uint256 _vstAmount, uint256 _mockPrice) + public + { _vstAmount = _boundToRealisticAmount(_vstAmount); + _boundAndSetMockPrice(_mockPrice); // Give user VST deal(address(vaultShareToken), user, _vstAmount); @@ -362,8 +411,9 @@ contract BuyYieldToken is ZapperTest { assertEq(_yt.balanceOf(address(zapper)), 0, "Zapper should have no remaining YT after buy"); } - function testFuzz_EmitsYieldTokenBoughtEvent(uint256 _vstAmount) public { + function testFuzz_EmitsYieldTokenBoughtEvent(uint256 _vstAmount, uint256 _mockPrice) public { _vstAmount = _boundToRealisticAmount(_vstAmount); + _boundAndSetMockPrice(_mockPrice); // Give user VST deal(address(vaultShareToken), user, _vstAmount); @@ -372,16 +422,17 @@ contract BuyYieldToken is ZapperTest { vm.prank(user); vaultShareToken.approve(address(zapper), _vstAmount); - // Expect event with correct user and vstSupplied (ytBought is checked separately) - vm.expectEmit(true, false, false, false); - emit Zapper.YieldTokenBought(user, 0, _vstAmount); + // Check indexed user; skip data fields (ytBought unknown before call) + vm.expectEmit(true, false, false, false, address(zapper)); + emit Zapper.YieldTokenBought(user, 0, 0); vm.prank(user); zapper.buyYieldToken(_vstAmount); } - function testFuzz_FlashLoanIsRepaidSuccessfully(uint256 _vstAmount) public { + function testFuzz_FlashLoanIsRepaidSuccessfully(uint256 _vstAmount, uint256 _mockPrice) public { _vstAmount = _boundToRealisticAmount(_vstAmount); + _boundAndSetMockPrice(_mockPrice); // Give user VST deal(address(vaultShareToken), user, _vstAmount); @@ -432,8 +483,9 @@ contract OnFlashLoan is ZapperTest { zapper.onFlashLoan(address(zapper), address(_pt), _amount, 0, ""); } - function testFuzz_ReturnsCorrectMagicValue(uint256 _amount) public { + function testFuzz_ReturnsCorrectMagicValue(uint256 _amount, uint256 _mockPrice) public { _amount = _boundToRealisticAmount(_amount); + _boundAndSetMockPrice(_mockPrice); // Setup: Deposit VST to get PT and YT _depositVstForUser(address(zapper), _amount); @@ -452,8 +504,9 @@ contract OnFlashLoan is ZapperTest { assertEq(_returnValue, FLASH_LOAN_RETURN_VALUE, "Should return correct magic value"); } - function testFuzz_SellPathApprovesHookForPTAndYT(uint256 _amount) public { + function testFuzz_SellPathApprovesHookForPTAndYT(uint256 _amount, uint256 _mockPrice) public { _amount = _boundToRealisticAmount(_amount); + _boundAndSetMockPrice(_mockPrice); // Setup: Deposit VST to get PT and YT _depositVstForUser(address(zapper), _amount); @@ -494,8 +547,9 @@ contract OnFlashLoan is ZapperTest { ); } - function testFuzz_SellPathApprovesPTForRepayment(uint256 _amount) public { + function testFuzz_SellPathApprovesPTForRepayment(uint256 _amount, uint256 _mockPrice) public { _amount = _boundToRealisticAmount(_amount); + _boundAndSetMockPrice(_mockPrice); // Setup: Deposit VST to get PT and YT _depositVstForUser(address(zapper), _amount); @@ -525,15 +579,18 @@ contract OnFlashLoan is ZapperTest { ); } - function testFuzz_BuyPathApprovesVSTForDeposit(uint256 _amount) public { + function testFuzz_BuyPathApprovesVSTForDeposit(uint256 _amount, uint256 _mockPrice) public { _amount = _boundToRealisticAmount(_amount); - - // Setup: Give zapper VST (simulating user's VST that was transferred) - deal(address(vaultShareToken), address(zapper), _amount); + _boundAndSetMockPrice(_mockPrice); // Get PT contract PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); + // Setup: Give zapper VST (simulating user's VST that was transferred) + deal(address(vaultShareToken), address(zapper), _amount); + // Give zapper PT (simulating flash loan having already transferred PT) + deal(address(_pt), address(zapper), _amount); + // Verify initial approval is zero assertEq( vaultShareToken.allowance(address(zapper), address(fixedFeeSwap)), @@ -551,15 +608,18 @@ contract OnFlashLoan is ZapperTest { assertTrue(true, "Buy path flash loan callback succeeded"); } - function testFuzz_BuyPathApprovesPTForRepayment(uint256 _amount) public { + function testFuzz_BuyPathApprovesPTForRepayment(uint256 _amount, uint256 _mockPrice) public { _amount = _boundToRealisticAmount(_amount); - - // Setup: Give zapper VST (simulating user's VST that was transferred) - deal(address(vaultShareToken), address(zapper), _amount); + _boundAndSetMockPrice(_mockPrice); // Get PT contract PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); + // Setup: Give zapper VST (simulating user's VST that was transferred) + deal(address(vaultShareToken), address(zapper), _amount); + // Give zapper PT (simulating flash loan having already transferred PT) + deal(address(_pt), address(zapper), _amount); + // Verify initial approval is zero assertEq( IERC20(address(_pt)).allowance(address(zapper), address(_pt)), diff --git a/test/harness/FixedFeeSwapMarketHarness.sol b/test/harness/FixedFeeSwapMarketHarness.sol index e8de4e2..5e34f5f 100644 --- a/test/harness/FixedFeeSwapMarketHarness.sol +++ b/test/harness/FixedFeeSwapMarketHarness.sol @@ -106,7 +106,7 @@ contract FixedFeeSwapMarketHarness is FixedFeeSwapMarket { return currentTick; } - function getCurrentPrice() external view returns (int256) { + function getRawCurrentPrice() external view returns (int256) { return currentPrice; } diff --git a/test/mock/FixedFeeSwapMarketMock.sol b/test/mock/FixedFeeSwapMarketMock.sol new file mode 100644 index 0000000..1ac1b13 --- /dev/null +++ b/test/mock/FixedFeeSwapMarketMock.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @notice Mock FixedFeeSwapMarket for Zapper unit tests. +/// @dev Provides controllable swap behavior without requiring actual liquidity. +/// Uses mockPrice for direction-dependent pricing: +/// - PT→Cash: output = input * price / WAD (PT is worth less than cash) +/// - Cash→PT: output = input * WAD / price (cash buys more PT since PT is discounted) +contract FixedFeeSwapMarketMock { + using SafeERC20 for IERC20; + + IERC20 public immutable TOKEN0; // Principal token + IERC20 public immutable TOKEN1; // Cash token (VST) + + /// @notice Mock price in WAD (1e18 = 1.0) + int256 public mockPrice; + + constructor(address _token0, address _token1) { + TOKEN0 = IERC20(_token0); + TOKEN1 = IERC20(_token1); + mockPrice = 0.98e18; // Default: 0.98 price + } + + /// @notice Set the mock price returned by getCurrentPrice() + function setMockPrice(int256 _price) external { + mockPrice = _price; + } + + /// @notice Returns the mock current price + function getCurrentPrice() external view returns (int256) { + return mockPrice; + } + + /// @notice Mock swap function with direction-dependent pricing based on mockPrice + /// @param _amountIn Amount of input tokens + /// @param _principalForCash True = PT->cash, False = cash->PT + /// @return amountInUsed Amount of input consumed (always full amount in mock) + /// @return amountOut Amount of output tokens + function swap( + uint256 _amountIn, + bool _principalForCash, + int24 /* _tickLimit */ + ) + external + returns (uint256 amountInUsed, uint256 amountOut) + { + amountInUsed = _amountIn; + uint256 _price = uint256(mockPrice); + + if (_principalForCash) { + // PT in -> Cash out: 1 PT = price cash (e.g., 0.98 VST) + amountOut = (_amountIn * _price) / 1e18; + TOKEN0.safeTransferFrom(msg.sender, address(this), amountInUsed); + TOKEN1.safeTransfer(msg.sender, amountOut); + } else { + // Cash in -> PT out: 1 cash = 1/price PT (e.g., ~1.0204 PT) + amountOut = (_amountIn * 1e18) / _price; + TOKEN1.safeTransferFrom(msg.sender, address(this), amountInUsed); + TOKEN0.safeTransfer(msg.sender, amountOut); + } + } + + /// @notice Convert tick to price (simplified mock - just returns mockPrice) + function tickToPrice(int24) external view returns (int256) { + return mockPrice; + } + + /// @notice Returns mock current tick (always 0 in mock) + function currentTick() external pure returns (int24) { + return 0; + } +}