From acd3c34bf13db4150e7284137c59cc5aef734349 Mon Sep 17 00:00:00 2001 From: Aditya Anand M C Date: Fri, 6 Feb 2026 11:44:05 +0530 Subject: [PATCH 01/10] Wire in swap market --- src/FixedFeeSwap.sol | 4 +- src/Zapper.sol | 67 ++++++++++-------- test/FixedFeeSwapMarket.t.sol | 1 - test/Zapper.unit.t.sol | 50 ++++++++++---- test/harness/FixedFeeSwapMarketHarness.sol | 4 -- test/mock/FixedFeeSwapMarketMock.sol | 80 ++++++++++++++++++++++ 6 files changed, 158 insertions(+), 48 deletions(-) create mode 100644 test/mock/FixedFeeSwapMarketMock.sol 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/Zapper.sol b/src/Zapper.sol index 239f984..45f2077 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(); @@ -121,7 +130,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); @@ -194,11 +205,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 +227,26 @@ 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. + /// @notice Swaps VST (cash) for PT using the FixedFeeSwapMarket + /// @param _availableVST The amount of VST available to swap + /// @param _neededPt The amount of PT needed to repay flash loan (unused, swap all available) + /// @return _swappedPT The amount of PT obtained from swapping VST 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 + // 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: This mock always leaves VST for the user since we swap 2x the needed PT + // 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..91978c0 100644 --- a/test/FixedFeeSwapMarket.t.sol +++ b/test/FixedFeeSwapMarket.t.sol @@ -3136,7 +3136,6 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { { _extraLiquidity = uint128(bound(uint256(_extraLiquidity), 1e18, 1e22)); _mintLiquidityAtTicks(alice, 0, 2000, _extraLiquidity); - uint128 _activeBefore = hook.getActiveLiquidity(); int24 _tickBefore = hook.getCurrentTick(); int256 _priceBefore = hook.tickToPrice(_tickBefore); diff --git a/test/Zapper.unit.t.sol b/test/Zapper.unit.t.sol index 878107a..36d06fe 100644 --- a/test/Zapper.unit.t.sol +++ b/test/Zapper.unit.t.sol @@ -9,6 +9,8 @@ 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 +19,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 +39,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 +66,8 @@ 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 Helper function to deposit VST for a user and get PT and YT tokens. @@ -89,6 +101,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,7 +109,14 @@ 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))); } } @@ -528,12 +548,14 @@ contract OnFlashLoan is ZapperTest { function testFuzz_BuyPathApprovesVSTForDeposit(uint256 _amount) public { _amount = _boundToRealisticAmount(_amount); - // Setup: Give zapper VST (simulating user's VST that was transferred) - deal(address(vaultShareToken), address(zapper), _amount); - // 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)), @@ -554,12 +576,14 @@ contract OnFlashLoan is ZapperTest { function testFuzz_BuyPathApprovesPTForRepayment(uint256 _amount) public { _amount = _boundToRealisticAmount(_amount); - // Setup: Give zapper VST (simulating user's VST that was transferred) - deal(address(vaultShareToken), address(zapper), _amount); - // 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..32acd11 100644 --- a/test/harness/FixedFeeSwapMarketHarness.sol +++ b/test/harness/FixedFeeSwapMarketHarness.sol @@ -106,10 +106,6 @@ contract FixedFeeSwapMarketHarness is FixedFeeSwapMarket { return currentTick; } - function getCurrentPrice() external view returns (int256) { - return currentPrice; - } - function getTickInfo(int24 tick) external view diff --git a/test/mock/FixedFeeSwapMarketMock.sol b/test/mock/FixedFeeSwapMarketMock.sol new file mode 100644 index 0000000..6d52c40 --- /dev/null +++ b/test/mock/FixedFeeSwapMarketMock.sol @@ -0,0 +1,80 @@ +// 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. +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; + + /// @notice Mock swap output ratio (percentage of input returned as output, in basis points) + /// @dev 10000 = 100% (1:1 swap for simplicity in unit tests) + uint256 public swapOutputRatioBps = 10_000; + + 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 Set the swap output ratio in basis points + /// @param _ratioBps Output ratio (e.g., 9800 = 98% of input) + function setSwapOutputRatioBps(uint256 _ratioBps) external { + swapOutputRatioBps = _ratioBps; + } + + /// @notice Returns the mock current price + function getCurrentPrice() external view returns (int256) { + return mockPrice; + } + + /// @notice Mock swap function that simulates trading + /// @param _amountIn Amount of input tokens + /// @param _principalForCash True = PT->cash, False = cash->PT + /// @param _priceLimitWad Price limit (ignored in mock) + /// @return amountInUsed Amount of input consumed (always full amount in mock) + /// @return amountOut Amount of output tokens + function swap(uint256 _amountIn, bool _principalForCash, uint256 _priceLimitWad) + external + returns (uint256 amountInUsed, uint256 amountOut) + { + // Silence unused variable warning + _priceLimitWad; + + amountInUsed = _amountIn; + amountOut = (_amountIn * swapOutputRatioBps) / 10_000; + + if (_principalForCash) { + // PT in -> Cash out + TOKEN0.safeTransferFrom(msg.sender, address(this), amountInUsed); + TOKEN1.safeTransfer(msg.sender, amountOut); + } else { + // Cash in -> PT out + 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; + } +} From d0f52bfba8ca4c80b23fca5e6cd4ca69a6207be2 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 09:18:15 -0500 Subject: [PATCH 02/10] Move external functions --- src/FixedFeeSwapMarket.sol | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/FixedFeeSwapMarket.sol b/src/FixedFeeSwapMarket.sol index 93383e3..57c8bff 100644 --- a/src/FixedFeeSwapMarket.sol +++ b/src/FixedFeeSwapMarket.sol @@ -396,6 +396,40 @@ 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 _priceLimitWad Price limit in WAD (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 _priceLimitWad) + external + returns (uint256 amountInUsed, uint256 amountOut) + { + // if (_amountIn == 0) revert FixedFeeSwap__SwapAmountCannotBeZero(); + //if (activeLiquidity == 0) revert FixedFeeSwap__NoActiveLiquidity(); + if (currentPrice == 0) currentPrice = tickToPrice(currentTick); + + (amountInUsed, amountOut) = _executeSwapLoop(_amountIn, _principalForCash, _priceLimitWad); + + // 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); + } + } + // ============ Internal Functions - Hook Overrides ============ /// @notice Hook called before liquidity is added via PoolManager From 7bb1126ff31ef2e889ae5089a4470606eb5ee112 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 09:18:34 -0500 Subject: [PATCH 03/10] Fix mock --- test/mock/FixedFeeSwapMarketMock.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mock/FixedFeeSwapMarketMock.sol b/test/mock/FixedFeeSwapMarketMock.sol index 6d52c40..017f8bf 100644 --- a/test/mock/FixedFeeSwapMarketMock.sol +++ b/test/mock/FixedFeeSwapMarketMock.sol @@ -47,7 +47,7 @@ contract FixedFeeSwapMarketMock { /// @param _priceLimitWad Price limit (ignored in mock) /// @return amountInUsed Amount of input consumed (always full amount in mock) /// @return amountOut Amount of output tokens - function swap(uint256 _amountIn, bool _principalForCash, uint256 _priceLimitWad) + function swap(uint256 _amountIn, bool _principalForCash, int24 _priceLimitWad) external returns (uint256 amountInUsed, uint256 amountOut) { From 856d27cf66f572947fe7ddaa41b27d0ea4bc0074 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 09:22:41 -0500 Subject: [PATCH 04/10] Fix linting errors --- test/Zapper.unit.t.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/Zapper.unit.t.sol b/test/Zapper.unit.t.sol index 36d06fe..99acdbc 100644 --- a/test/Zapper.unit.t.sol +++ b/test/Zapper.unit.t.sol @@ -3,7 +3,6 @@ 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 @@ -45,15 +44,15 @@ contract ZapperTest is Test { fixedFeeSwap = new FixedFeeSwap(vaultShareToken, maturity); // Create mock FixedFeeSwapMarket for PT/VST trading - PrincipalToken pt = fixedFeeSwap.PRINCIPAL_TOKEN(); + PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); fixedFeeSwapMarketMock = new FixedFeeSwapMarketMock( - address(pt), // token0 = PT (principal) + 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(_pt), address(fixedFeeSwapMarketMock), 1e30); deal(address(vaultShareToken), address(fixedFeeSwapMarketMock), 1e30); zapper = new Zapper(fixedFeeSwap, FixedFeeSwapMarket(address(fixedFeeSwapMarketMock))); From e67daaf25c8b12dd90bbfcda32113018c5c35f00 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 09:25:51 -0500 Subject: [PATCH 05/10] Small changes --- src/FixedFeeSwapMarket.sol | 13 +++++++------ test/mock/FixedFeeSwapMarketMock.sol | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/FixedFeeSwapMarket.sol b/src/FixedFeeSwapMarket.sol index 57c8bff..87e07ad 100644 --- a/src/FixedFeeSwapMarket.sol +++ b/src/FixedFeeSwapMarket.sol @@ -405,18 +405,19 @@ contract FixedFeeSwapMarket is BaseHook { /// @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 _priceLimitWad Price limit in WAD (0 = no limit) + /// @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 _priceLimitWad) + function swap(uint256 _amountIn, bool _principalForCash, int24 _tickLimit) external returns (uint256 amountInUsed, uint256 amountOut) { - // if (_amountIn == 0) revert FixedFeeSwap__SwapAmountCannotBeZero(); - //if (activeLiquidity == 0) revert FixedFeeSwap__NoActiveLiquidity(); - if (currentPrice == 0) currentPrice = tickToPrice(currentTick); + if (_amountIn == 0) revert FixedFeeSwapMarket_SwapAmountCannotBeZero(); + if (block.timestamp >= MATURITY) revert FixedFeeSwapMarket_MarketExpired(); + if (activeLiquidity == 0) revert FixedFeeSwapMarket_NoActiveLiquidity(); + currentPrice = tickToPrice(currentTick); - (amountInUsed, amountOut) = _executeSwapLoop(_amountIn, _principalForCash, _priceLimitWad); + (amountInUsed, amountOut) = _executeSwapLoop(_amountIn, _principalForCash, _tickLimit); // Execute token transfers if (_principalForCash) { diff --git a/test/mock/FixedFeeSwapMarketMock.sol b/test/mock/FixedFeeSwapMarketMock.sol index 017f8bf..2d8969d 100644 --- a/test/mock/FixedFeeSwapMarketMock.sol +++ b/test/mock/FixedFeeSwapMarketMock.sol @@ -44,15 +44,15 @@ contract FixedFeeSwapMarketMock { /// @notice Mock swap function that simulates trading /// @param _amountIn Amount of input tokens /// @param _principalForCash True = PT->cash, False = cash->PT - /// @param _priceLimitWad Price limit (ignored in mock) + /// @param _tickLimit Tick limit (ignored in mock) /// @return amountInUsed Amount of input consumed (always full amount in mock) /// @return amountOut Amount of output tokens - function swap(uint256 _amountIn, bool _principalForCash, int24 _priceLimitWad) + function swap(uint256 _amountIn, bool _principalForCash, int24 _tickLimit) external returns (uint256 amountInUsed, uint256 amountOut) { // Silence unused variable warning - _priceLimitWad; + _tickLimit; amountInUsed = _amountIn; amountOut = (_amountIn * swapOutputRatioBps) / 10_000; From 1e2de8cfb72e907f43c82c283560c180952483e2 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 09:34:22 -0500 Subject: [PATCH 06/10] Minor updates --- src/FixedFeeSwapMarket.sol | 2 ++ src/Zapper.sol | 8 ++---- test/FixedFeeSwapMarket.t.sol | 3 +- test/harness/FixedFeeSwapMarketHarness.sol | 4 +++ test/mock/FixedFeeSwapMarketMock.sol | 33 +++++++++------------- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/FixedFeeSwapMarket.sol b/src/FixedFeeSwapMarket.sol index 87e07ad..f4150c1 100644 --- a/src/FixedFeeSwapMarket.sol +++ b/src/FixedFeeSwapMarket.sol @@ -429,6 +429,8 @@ contract FixedFeeSwapMarket is BaseHook { 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 ============ diff --git a/src/Zapper.sol b/src/Zapper.sol index 45f2077..9f071f4 100644 --- a/src/Zapper.sol +++ b/src/Zapper.sol @@ -196,7 +196,7 @@ contract Zapper is IERC3156FlashBorrower { uint256 _vstMinted = FIXED_FEE_SWAP.redeem(_amount); // Calculate and swap portion of VST for PT to repay flash loan - uint256 _swappedPT = _calculateAndSwapVSTForPT(_vstMinted, _amount); + uint256 _swappedPT = _calculateAndSwapVSTForPT(_vstMinted); // Approve PT contract to spend PT to repay flash loan IERC20(address(PRINCIPAL_TOKEN)).approve(address(PRINCIPAL_TOKEN), _swappedPT); @@ -229,12 +229,8 @@ contract Zapper is IERC3156FlashBorrower { /// @notice Swaps VST (cash) for PT using the FixedFeeSwapMarket /// @param _availableVST The amount of VST available to swap - /// @param _neededPt The amount of PT needed to repay flash loan (unused, swap all available) /// @return _swappedPT The amount of PT obtained from swapping VST - function _calculateAndSwapVSTForPT(uint256 _availableVST, uint256 _neededPt) - internal - returns (uint256 _swappedPT) - { + function _calculateAndSwapVSTForPT(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); diff --git a/test/FixedFeeSwapMarket.t.sol b/test/FixedFeeSwapMarket.t.sol index 91978c0..f218ca5 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()); @@ -3136,6 +3136,7 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { { _extraLiquidity = uint128(bound(uint256(_extraLiquidity), 1e18, 1e22)); _mintLiquidityAtTicks(alice, 0, 2000, _extraLiquidity); + uint128 _activeBefore = hook.getActiveLiquidity(); int24 _tickBefore = hook.getCurrentTick(); int256 _priceBefore = hook.tickToPrice(_tickBefore); diff --git a/test/harness/FixedFeeSwapMarketHarness.sol b/test/harness/FixedFeeSwapMarketHarness.sol index 32acd11..5e34f5f 100644 --- a/test/harness/FixedFeeSwapMarketHarness.sol +++ b/test/harness/FixedFeeSwapMarketHarness.sol @@ -106,6 +106,10 @@ contract FixedFeeSwapMarketHarness is FixedFeeSwapMarket { return currentTick; } + function getRawCurrentPrice() external view returns (int256) { + return currentPrice; + } + function getTickInfo(int24 tick) external view diff --git a/test/mock/FixedFeeSwapMarketMock.sol b/test/mock/FixedFeeSwapMarketMock.sol index 2d8969d..1ac1b13 100644 --- a/test/mock/FixedFeeSwapMarketMock.sol +++ b/test/mock/FixedFeeSwapMarketMock.sol @@ -6,6 +6,9 @@ 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; @@ -15,10 +18,6 @@ contract FixedFeeSwapMarketMock { /// @notice Mock price in WAD (1e18 = 1.0) int256 public mockPrice; - /// @notice Mock swap output ratio (percentage of input returned as output, in basis points) - /// @dev 10000 = 100% (1:1 swap for simplicity in unit tests) - uint256 public swapOutputRatioBps = 10_000; - constructor(address _token0, address _token1) { TOKEN0 = IERC20(_token0); TOKEN1 = IERC20(_token1); @@ -30,39 +29,35 @@ contract FixedFeeSwapMarketMock { mockPrice = _price; } - /// @notice Set the swap output ratio in basis points - /// @param _ratioBps Output ratio (e.g., 9800 = 98% of input) - function setSwapOutputRatioBps(uint256 _ratioBps) external { - swapOutputRatioBps = _ratioBps; - } - /// @notice Returns the mock current price function getCurrentPrice() external view returns (int256) { return mockPrice; } - /// @notice Mock swap function that simulates trading + /// @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 - /// @param _tickLimit Tick limit (ignored in mock) /// @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) + function swap( + uint256 _amountIn, + bool _principalForCash, + int24 /* _tickLimit */ + ) external returns (uint256 amountInUsed, uint256 amountOut) { - // Silence unused variable warning - _tickLimit; - amountInUsed = _amountIn; - amountOut = (_amountIn * swapOutputRatioBps) / 10_000; + uint256 _price = uint256(mockPrice); if (_principalForCash) { - // PT in -> Cash out + // 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 + // 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); } From 66ec3d832260ff44293da590b6522ea212bef558 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 09:56:49 -0500 Subject: [PATCH 07/10] More minor changes --- src/Zapper.sol | 18 +++++++-- test/Zapper.unit.t.sol | 92 ++++++++++++++++++++++++++++++------------ 2 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/Zapper.sol b/src/Zapper.sol index 9f071f4..fb3fa00 100644 --- a/src/Zapper.sol +++ b/src/Zapper.sol @@ -114,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); } @@ -193,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); + // Swap only the needed VST for PT to repay flash loan + uint256 _swappedPT = _calculateAndSwapVSTForPT(_vstNeeded); // Approve PT contract to spend PT to repay flash loan IERC20(address(PRINCIPAL_TOKEN)).approve(address(PRINCIPAL_TOKEN), _swappedPT); diff --git a/test/Zapper.unit.t.sol b/test/Zapper.unit.t.sol index 99acdbc..a0673f4 100644 --- a/test/Zapper.unit.t.sol +++ b/test/Zapper.unit.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.26; // External imports -import {Test} from "forge-std/Test.sol"; +import {Test, Vm} from "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // Internal imports @@ -69,6 +69,15 @@ contract ZapperTest is Test { 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. /// @param _user The address to deposit for. /// @param _depositAmount The amount of VST to deposit. @@ -120,8 +129,9 @@ contract Constructor is ZapperTest { } 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); @@ -148,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); @@ -169,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); @@ -205,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); @@ -225,16 +246,34 @@ 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); - + // Execute sell and record logs + vm.recordLogs(); vm.prank(user); zapper.sellYieldToken(_ytAmount); + + // Find the YieldTokenSold event and verify all fields + Vm.Log[] memory _logs = vm.getRecordedLogs(); + bytes32 _eventSig = keccak256("YieldTokenSold(address,uint256,uint256)"); + bool _found; + for (uint256 _i; _i < _logs.length; _i++) { + if (_logs[_i].topics[0] == _eventSig) { + // Verify indexed user field + assertEq(_logs[_i].topics[1], bytes32(uint256(uint160(user))), "Event user mismatch"); + // Decode non-indexed fields + (uint256 _ytAmountEmitted, uint256 _vstReceivedEmitted) = + abi.decode(_logs[_i].data, (uint256, uint256)); + assertEq(_ytAmountEmitted, _ytAmount, "Event ytAmount mismatch"); + assertGt(_vstReceivedEmitted, 0, "Event vstReceived should be non-zero"); + _found = true; + break; + } + } + assertTrue(_found, "YieldTokenSold event not emitted"); } - 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); @@ -451,8 +490,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); @@ -471,8 +511,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); @@ -513,8 +554,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); From 7e55d7b60ea389a4f6fad865fa1b9a16ac1d9ed4 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 10:46:42 -0500 Subject: [PATCH 08/10] More minor changes --- src/FixedFeeSwapMarket.sol | 3 +- src/Zapper.sol | 4 +- test/FixedFeeSwapMarket.t.sol | 104 +++++++++++++++++++++++++++++++++- test/Zapper.unit.t.sol | 57 ++++++++++++++----- 4 files changed, 150 insertions(+), 18 deletions(-) diff --git a/src/FixedFeeSwapMarket.sol b/src/FixedFeeSwapMarket.sol index f4150c1..c491ce4 100644 --- a/src/FixedFeeSwapMarket.sol +++ b/src/FixedFeeSwapMarket.sol @@ -417,7 +417,8 @@ contract FixedFeeSwapMarket is BaseHook { if (activeLiquidity == 0) revert FixedFeeSwapMarket_NoActiveLiquidity(); currentPrice = tickToPrice(currentTick); - (amountInUsed, amountOut) = _executeSwapLoop(_amountIn, _principalForCash, _tickLimit); + int24 _effectiveTickLimit = _tickLimit == 0 ? NO_TICK_LIMIT : _tickLimit; + (amountInUsed, amountOut) = _executeSwapLoop(_amountIn, _principalForCash, _effectiveTickLimit); // Execute token transfers if (_principalForCash) { diff --git a/src/Zapper.sol b/src/Zapper.sol index fb3fa00..593db00 100644 --- a/src/Zapper.sol +++ b/src/Zapper.sol @@ -206,7 +206,7 @@ contract Zapper is IERC3156FlashBorrower { uint256 _vstNeeded = Math.mulDiv(_amount, _ptPrice, FeeTracking.PRECISION, Math.Rounding.Ceil); // Swap only the needed VST for PT to repay flash loan - uint256 _swappedPT = _calculateAndSwapVSTForPT(_vstNeeded); + uint256 _swappedPT = _swapVSTForPT(_vstNeeded); // Approve PT contract to spend PT to repay flash loan IERC20(address(PRINCIPAL_TOKEN)).approve(address(PRINCIPAL_TOKEN), _swappedPT); @@ -240,7 +240,7 @@ contract Zapper is IERC3156FlashBorrower { /// @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 _calculateAndSwapVSTForPT(uint256 _availableVST) internal returns (uint256 _swappedPT) { + 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); diff --git a/test/FixedFeeSwapMarket.t.sol b/test/FixedFeeSwapMarket.t.sol index f218ca5..9eaeca3 100644 --- a/test/FixedFeeSwapMarket.t.sol +++ b/test/FixedFeeSwapMarket.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import {Test} from "forge-std/Test.sol"; +import {Test, Vm} from "forge-std/Test.sol"; import {stdError} from "forge-std/StdError.sol"; import {FixedFeeSwapMarket} from "src/FixedFeeSwapMarket.sol"; import {FixedFeeSwapMarketHarness} from "test/harness/FixedFeeSwapMarketHarness.sol"; @@ -3882,6 +3882,107 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { vm.prank(_caller); feeHook.collectFees(_positionId, _caller, 0, 0); } + + // ============ Direct Swap Tests (FixedFeeSwapMarket.swap) ============ + + function test_DirectSwap_RevertIf_AmountIsZero() public { + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_SwapAmountCannotBeZero.selector); + hook.swap(0, true, 0); + } + + function test_DirectSwap_RevertIf_MarketExpired() public { + vm.warp(hook.getStartTime() + MARKET_LENGTH); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_MarketExpired.selector); + hook.swap(1e18, true, 0); + } + + function test_DirectSwap_RevertIf_NoActiveLiquidity() public { + // 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(1e18, true, 0); + } + + function testFuzz_DirectSwap_PrincipalForCashTransfersCorrectTokens(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_DirectSwap_CashForPrincipalTransfersCorrectTokens(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_DirectSwap_EmitsSwapEvent(uint256 _amount) public { + _amount = bound(_amount, _ptForOneBip() * 2, 1e20); + + vm.recordLogs(); + vm.prank(bob); + (uint256 _amountInUsed, uint256 _amountOut) = hook.swap(_amount, true, 0); + + Vm.Log[] memory _logs = vm.getRecordedLogs(); + bytes32 _eventSig = keccak256("Swap(address,bool,uint256,uint256)"); + bool _found; + for (uint256 _i; _i < _logs.length; _i++) { + if (_logs[_i].topics[0] == _eventSig) { + assertEq(_logs[_i].topics[1], bytes32(uint256(uint160(bob))), "Event sender should be bob"); + (bool _principalForCash, uint256 _emittedIn, uint256 _emittedOut) = + abi.decode(_logs[_i].data, (bool, uint256, uint256)); + assertTrue(_principalForCash, "Event should indicate principalForCash"); + assertEq(_emittedIn, _amountInUsed, "Event amountIn mismatch"); + assertEq(_emittedOut, _amountOut, "Event amountOut mismatch"); + _found = true; + break; + } + } + assertTrue(_found, "Swap event not emitted"); + } + + function testFuzz_DirectSwap_DoesNotConsumeMoreThanRequested(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"); + } } contract _BeforeAddLiquidity is FixedFeeSwapMarketTest { @@ -4202,3 +4303,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 a0673f4..f163d38 100644 --- a/test/Zapper.unit.t.sol +++ b/test/Zapper.unit.t.sol @@ -301,8 +301,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); @@ -327,8 +328,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); @@ -359,11 +361,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"); @@ -396,8 +401,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); @@ -420,8 +428,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); @@ -430,16 +439,34 @@ 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); - + // Execute buy and record logs + vm.recordLogs(); vm.prank(user); zapper.buyYieldToken(_vstAmount); + + // Find the YieldTokenBought event and verify all fields + Vm.Log[] memory _logs = vm.getRecordedLogs(); + bytes32 _eventSig = keccak256("YieldTokenBought(address,uint256,uint256)"); + bool _found; + for (uint256 _i; _i < _logs.length; _i++) { + if (_logs[_i].topics[0] == _eventSig) { + // Verify indexed user field + assertEq(_logs[_i].topics[1], bytes32(uint256(uint160(user))), "Event user mismatch"); + // Decode non-indexed fields + (uint256 _ytBoughtEmitted, uint256 _vstSuppliedEmitted) = + abi.decode(_logs[_i].data, (uint256, uint256)); + assertGt(_ytBoughtEmitted, 0, "Event ytBought should be non-zero"); + assertEq(_vstSuppliedEmitted, _vstAmount, "Event vstSupplied mismatch"); + _found = true; + break; + } + } + assertTrue(_found, "YieldTokenBought event not emitted"); } - 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); @@ -586,8 +613,9 @@ contract OnFlashLoan is ZapperTest { ); } - function testFuzz_BuyPathApprovesVSTForDeposit(uint256 _amount) public { + function testFuzz_BuyPathApprovesVSTForDeposit(uint256 _amount, uint256 _mockPrice) public { _amount = _boundToRealisticAmount(_amount); + _boundAndSetMockPrice(_mockPrice); // Get PT contract PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); @@ -614,8 +642,9 @@ 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); + _boundAndSetMockPrice(_mockPrice); // Get PT contract PrincipalToken _pt = fixedFeeSwap.PRINCIPAL_TOKEN(); From 6d214d597fab0b1af819ee629d21dff887f2dfdf Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 26 Feb 2026 11:40:36 -0500 Subject: [PATCH 09/10] Change tests --- test/FixedFeeSwapMarket.t.sol | 98 ++++++++++++++++------------------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/test/FixedFeeSwapMarket.t.sol b/test/FixedFeeSwapMarket.t.sol index 9eaeca3..7ae255e 100644 --- a/test/FixedFeeSwapMarket.t.sol +++ b/test/FixedFeeSwapMarket.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import {Test, Vm} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {stdError} from "forge-std/StdError.sol"; import {FixedFeeSwapMarket} from "src/FixedFeeSwapMarket.sol"; import {FixedFeeSwapMarketHarness} from "test/harness/FixedFeeSwapMarketHarness.sol"; @@ -3885,36 +3885,7 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { // ============ Direct Swap Tests (FixedFeeSwapMarket.swap) ============ - function test_DirectSwap_RevertIf_AmountIsZero() public { - vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_SwapAmountCannotBeZero.selector); - hook.swap(0, true, 0); - } - - function test_DirectSwap_RevertIf_MarketExpired() public { - vm.warp(hook.getStartTime() + MARKET_LENGTH); - vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_MarketExpired.selector); - hook.swap(1e18, true, 0); - } - - function test_DirectSwap_RevertIf_NoActiveLiquidity() public { - // 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(1e18, true, 0); - } - - function testFuzz_DirectSwap_PrincipalForCashTransfersCorrectTokens(uint256 _amount) public { + function testFuzz_DirectSwapPrincipalForCashTransfersCorrectTokens(uint256 _amount) public { // Minimum 2 bips (2 * activeLiquidity / BIPS) for guaranteed tick movement _amount = bound(_amount, _ptForOneBip() * 2, 1e20); @@ -3930,7 +3901,7 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { assertEq(token1.balanceOf(bob), _token1Before + _amountOut, "Cash should increase by amountOut"); } - function testFuzz_DirectSwap_CashForPrincipalTransfersCorrectTokens(uint256 _amount) public { + function testFuzz_DirectSwapCashForPrincipalTransfersCorrectTokens(uint256 _amount) public { // Minimum 2 bips for guaranteed tick movement _amount = bound(_amount, _ptForOneBip() * 2, 1e20); @@ -3948,32 +3919,18 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { assertEq(token0.balanceOf(bob), _token0Before + _amountOut, "PT should increase by amountOut"); } - function testFuzz_DirectSwap_EmitsSwapEvent(uint256 _amount) public { + function testFuzz_DirectSwapEmitsSwapEvent(uint256 _amount) public { _amount = bound(_amount, _ptForOneBip() * 2, 1e20); - vm.recordLogs(); - vm.prank(bob); - (uint256 _amountInUsed, uint256 _amountOut) = hook.swap(_amount, true, 0); + // 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.Log[] memory _logs = vm.getRecordedLogs(); - bytes32 _eventSig = keccak256("Swap(address,bool,uint256,uint256)"); - bool _found; - for (uint256 _i; _i < _logs.length; _i++) { - if (_logs[_i].topics[0] == _eventSig) { - assertEq(_logs[_i].topics[1], bytes32(uint256(uint160(bob))), "Event sender should be bob"); - (bool _principalForCash, uint256 _emittedIn, uint256 _emittedOut) = - abi.decode(_logs[_i].data, (bool, uint256, uint256)); - assertTrue(_principalForCash, "Event should indicate principalForCash"); - assertEq(_emittedIn, _amountInUsed, "Event amountIn mismatch"); - assertEq(_emittedOut, _amountOut, "Event amountOut mismatch"); - _found = true; - break; - } - } - assertTrue(_found, "Swap event not emitted"); + vm.prank(bob); + hook.swap(_amount, true, 0); } - function testFuzz_DirectSwap_DoesNotConsumeMoreThanRequested(uint256 _amount) public { + function testFuzz_DirectSwapDoesNotConsumeMoreThanRequested(uint256 _amount) public { _amount = bound(_amount, _ptForOneBip() * 2, 1e20); vm.prank(bob); @@ -3983,6 +3940,41 @@ contract _BeforeSwap is FixedFeeSwapMarketTest { 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 { From 7de6fb637b66874ca519d6574903068361f57549 Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 26 Feb 2026 11:45:04 -0500 Subject: [PATCH 10/10] Update tests --- test/Zapper.unit.t.sol | 52 ++++++++---------------------------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/test/Zapper.unit.t.sol b/test/Zapper.unit.t.sol index f163d38..1a22d41 100644 --- a/test/Zapper.unit.t.sol +++ b/test/Zapper.unit.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.26; // External imports -import {Test, Vm} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // Internal imports @@ -246,29 +246,12 @@ contract SellYieldToken is ZapperTest { vm.prank(user); _yt.approve(address(zapper), _ytAmount); - // Execute sell and record logs - vm.recordLogs(); + // 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); - - // Find the YieldTokenSold event and verify all fields - Vm.Log[] memory _logs = vm.getRecordedLogs(); - bytes32 _eventSig = keccak256("YieldTokenSold(address,uint256,uint256)"); - bool _found; - for (uint256 _i; _i < _logs.length; _i++) { - if (_logs[_i].topics[0] == _eventSig) { - // Verify indexed user field - assertEq(_logs[_i].topics[1], bytes32(uint256(uint160(user))), "Event user mismatch"); - // Decode non-indexed fields - (uint256 _ytAmountEmitted, uint256 _vstReceivedEmitted) = - abi.decode(_logs[_i].data, (uint256, uint256)); - assertEq(_ytAmountEmitted, _ytAmount, "Event ytAmount mismatch"); - assertGt(_vstReceivedEmitted, 0, "Event vstReceived should be non-zero"); - _found = true; - break; - } - } - assertTrue(_found, "YieldTokenSold event not emitted"); } function testFuzz_FlashLoanIsRepaidSuccessfully(uint256 _ytAmount, uint256 _mockPrice) public { @@ -439,29 +422,12 @@ contract BuyYieldToken is ZapperTest { vm.prank(user); vaultShareToken.approve(address(zapper), _vstAmount); - // Execute buy and record logs - vm.recordLogs(); + // 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); - - // Find the YieldTokenBought event and verify all fields - Vm.Log[] memory _logs = vm.getRecordedLogs(); - bytes32 _eventSig = keccak256("YieldTokenBought(address,uint256,uint256)"); - bool _found; - for (uint256 _i; _i < _logs.length; _i++) { - if (_logs[_i].topics[0] == _eventSig) { - // Verify indexed user field - assertEq(_logs[_i].topics[1], bytes32(uint256(uint160(user))), "Event user mismatch"); - // Decode non-indexed fields - (uint256 _ytBoughtEmitted, uint256 _vstSuppliedEmitted) = - abi.decode(_logs[_i].data, (uint256, uint256)); - assertGt(_ytBoughtEmitted, 0, "Event ytBought should be non-zero"); - assertEq(_vstSuppliedEmitted, _vstAmount, "Event vstSupplied mismatch"); - _found = true; - break; - } - } - assertTrue(_found, "YieldTokenBought event not emitted"); } function testFuzz_FlashLoanIsRepaidSuccessfully(uint256 _vstAmount, uint256 _mockPrice) public {