Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/FixedFeeSwap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions src/FixedFeeSwapMarket.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 54 additions & 37 deletions src/Zapper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}

Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
}
}
96 changes: 95 additions & 1 deletion test/FixedFeeSwapMarket.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -4202,3 +4295,4 @@ contract LiquidityGap is FixedFeeSwapMarketTest {
_swap(bob, int256(_cashInToBoundary + 1), false, NO_TICK_LIMIT);
}
}

Loading
Loading