Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.17;

import "../../SYBaseUpg.sol";
import "../../../../interfaces/IERC4626.sol";

import "../PendleERC4626UpgSYV2.sol";

interface IInfiniFiGateway {
function stake(address _to, uint256 _receiptTokens) external returns (uint256);

function redeem(address _to, uint256 _amount, uint256 _minAssetsOut) external returns (uint256);

function unstake(address _to, uint256 _stakedTokens) external returns (uint256);

function getAddress(string memory _name) external view returns (address);

function mintAndStake(address _to, uint256 _amount) external returns (uint256);
}

interface IYieldSharing {
function vested() external view returns (uint256);
}

interface IRedeemController {
function receiptToAsset(uint256 _receiptAmount) external view returns (uint256);
}

interface IMintController {
function assetToReceipt(uint256 _assetAmount) external view returns (uint256);
}

contract PendleInfinifiSIUSD is PendleERC4626UpgSYV2 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sharing here my actual testing script. It can be used to see some of the issues already mentioned:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

/*  ────────────────────────────────────────────────────────────────
Run with:
forge test -vvvv --match-contract EnvTests --match-test test_setUp
forge test -vvvv --match-contract EnvTests --match-test test_USDC_paths
forge test -vvvv --match-contract EnvTests --match-test test_IUSD_paths
forge test -vvvv --match-contract EnvTests --match-test test_SIUSD_paths
──────────────────────────────────────────────────────────────── */

import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IStandardizedYield} from "contracts/interfaces/IStandardizedYield.sol";
import {IInfiniFiGatewayV2} from "./IInfiniFiGatewayV2.sol";
import {IIUSD} from "./IIUSD.sol";
import {ISIUSD} from "./ISIUSD.sol";
import {PendleInfinifiSIUSD} from "contracts/core/StandardizedYield/implementations/InfiniFi/PendleInfiniFisiUSD.sol";

contract EnvTests is Test {
    // Forking state variables
    string public MAINNET_RPC_URL = "https://eth.llamarpc.com";
    uint256 public MAINNET_FORK_ID;

    /* Actors */
    address public owner  = vm.addr(100);
    address public user1  = vm.addr(101);
    address public user2  = vm.addr(102);
    address public user3  = vm.addr(103);
    address public user4  = vm.addr(104);
    address public user5  = vm.addr(105);
    address public user6  = vm.addr(106);
    address public user7  = vm.addr(107);
    address public user8  = vm.addr(108);
    address public user9  = vm.addr(109);
    address public user10 = vm.addr(110);

    IERC20 public contract_USDC    = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
    IIUSD  public contract_IUSD    = IIUSD(0x48f9e38f3070AD8945DFEae3FA70987722E3D89c);
    ISIUSD public contract_SIUSD   = ISIUSD(0xDBDC1Ef57537E34680B898E1FEBD3D68c7389bCB);

    // InfiniFi
    IInfiniFiGatewayV2 public contract_GatewayV2 = IInfiniFiGatewayV2(0x3f04b65Ddbd87f9CE0A2e7Eb24d80e7fb87625b5);

    // Core
    PendleInfinifiSIUSD public contract_PendleInfinifiSIUSD;

    /* ───────── set-up ───────── */
    function setUp() public {
        MAINNET_FORK_ID = vm.createFork(MAINNET_RPC_URL, 22879448); // Jul-09-2025 05:09:23 AM +UTC
        vm.selectFork(MAINNET_FORK_ID);

        vm.startPrank(owner);
        contract_PendleInfinifiSIUSD = new PendleInfinifiSIUSD();
        contract_PendleInfinifiSIUSD.initialize("PendleInfinifiSIUSD", "PenInfSIUSD");
        vm.stopPrank();
    }

    function test_setUp() public view {
        console.log("test_setUp()");
        console.log("____________________________________");
        console.log("contract_IUSD.decimals() -> %s", contract_IUSD.decimals());
        console.log("contract_SIUSD.decimals() -> %s", contract_SIUSD.decimals());
        console.log("contract_PendleInfinifiSIUSD.exchangeRate() -> %s", contract_PendleInfinifiSIUSD.exchangeRate());
        (IStandardizedYield.AssetType assetType, address assetAddress, uint8 assetDecimals) =
            contract_PendleInfinifiSIUSD.assetInfo();
        console.log("assetInfo() -> %s, %s, %s", uint8(assetType), assetAddress, assetDecimals);

        address[] memory ins  = contract_PendleInfinifiSIUSD.getTokensIn();
        address[] memory outs = contract_PendleInfinifiSIUSD.getTokensOut();
        console.log("getTokensIn    -> %s, %s, %s", ins[0], ins[1], ins[2]);
        console.log("getTokensOut   -> %s, %s, %s", outs[0], outs[1], outs[2]);
    }

    /// @dev Helper to convert USDC → IUSD for `user`
    function _prepareIUSD(address user, uint256 usdcAmt) internal returns (uint256 iusdAmt) {
        vm.startPrank(user);
        contract_USDC.approve(address(contract_GatewayV2), usdcAmt);
        iusdAmt = contract_GatewayV2.mint(user, usdcAmt);
        vm.stopPrank();
    }

    /// @dev Helper to convert USDC → SIUSD for `user`
    function _prepareSIUSD(address user, uint256 usdcAmt) internal returns (uint256 siAmt) {
        vm.startPrank(user);
        contract_USDC.approve(address(contract_GatewayV2), usdcAmt);
        contract_GatewayV2.mintAndStake(user, usdcAmt);
        siAmt = contract_SIUSD.balanceOf(user);
        vm.stopPrank();
    }

    function _checkDust() internal {
        console.log("");
        console.log("contract_USDC.balanceOf(contract_PendleInfinifiSIUSD) -> %e", contract_USDC.balanceOf(address(contract_PendleInfinifiSIUSD)));
        console.log("contract_IUSD.balanceOf(contract_PendleInfinifiSIUSD) -> %e", contract_IUSD.balanceOf(address(contract_PendleInfinifiSIUSD)));
        console.log("contract_SIUSD.balanceOf(contract_PendleInfinifiSIUSD) -> %e", contract_SIUSD.balanceOf(address(contract_PendleInfinifiSIUSD)));
        console.log("");
    }

    // ───────────────────────── USDC deposit & all redeem paths ─────────────────────────

    function test_USDC_paths() public {
        uint256 amt = 100e6; // 100 USDC
        deal(address(contract_USDC), user1, amt);

        vm.startPrank(user1);
        console.log("\n--- USDC PATH ---");

        // APPROVE
        contract_USDC.approve(address(contract_PendleInfinifiSIUSD), amt);

        // PREVIEW DEPOSIT
        uint256 previewSharesUSDC = contract_PendleInfinifiSIUSD.previewDeposit(address(contract_USDC), amt);
        console.log("previewDeposit(USDC) -> %e", previewSharesUSDC);

        // DEPOSIT
        uint256 shares = contract_PendleInfinifiSIUSD.deposit(user1, address(contract_USDC), amt, 0);
        console.log("deposit(USDC) -> shares = %e", shares);
        _checkDust();
        uint256 snapshot = vm.snapshot();

        // REDEEM -> USDC
        console.log("\nredeem -> USDC");
        uint256 previewUSDC = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_USDC), shares);
        console.log("previewRedeem(USDC) -> %e", previewUSDC);
        uint256 outUSDC = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_USDC), 0, false);
        console.log("redeem(USDC) -> out = %e", outUSDC);
        _checkDust();
        vm.revertTo(snapshot);

        // REDEEM -> IUSD
        console.log("\nredeem -> IUSD");
        uint256 previewIUSD = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_IUSD), shares);
        console.log("previewRedeem(IUSD) -> %e", previewIUSD);
        uint256 outIUSD = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_IUSD), 0, false);
        console.log("redeem(IUSD) -> out = %e", outIUSD);
        _checkDust();
        vm.revertTo(snapshot);

        // REDEEM -> SIUSD (known bug)
        console.log("\nredeem -> SIUSD");
        uint256 previewSIUSD = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_SIUSD), shares);
        console.log("previewRedeem(SIUSD) -> %e", previewSIUSD);
        uint256 outSI = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_SIUSD), 0, false);
        console.log("redeem(SIUSD) -> out = %e", outSI);
        _checkDust();
        vm.stopPrank();
    }

    // ───────────────────────── IUSD deposit & all redeem paths ─────────────────────────

    function test_IUSD_paths() public {
        deal(address(contract_USDC), user1, 200e6);
        uint256 iusdAmt = _prepareIUSD(user1, 200e6);
        assertGt(iusdAmt, 0, "prepareIUSD failed");

        vm.startPrank(user1);
        console.log("\n--- IUSD PATH ---");

        // APPROVE
        contract_IUSD.approve(address(contract_PendleInfinifiSIUSD), iusdAmt);

        // PREVIEW DEPOSIT
        uint256 previewSharesIUSD = contract_PendleInfinifiSIUSD.previewDeposit(address(contract_IUSD), iusdAmt);
        console.log("previewDeposit(IUSD) -> %e", previewSharesIUSD);

        // DEPOSIT
        uint256 shares = contract_PendleInfinifiSIUSD.deposit(user1, address(contract_IUSD), iusdAmt, 0);
        console.log("deposit(IUSD) -> shares = %e", shares);
        _checkDust();
        uint256 snapshot = vm.snapshot();

        // REDEEM -> IUSD
        console.log("\nredeem -> IUSD");
        uint256 previewIUSD_Back = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_IUSD), shares);
        console.log("previewRedeem(IUSD) -> %e", previewIUSD_Back);
        uint256 backIUSD = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_IUSD), 0, false);
        console.log("redeem(IUSD) -> out = %e", backIUSD);
        _checkDust();
        vm.revertTo(snapshot);

        // REDEEM -> USDC
        console.log("\nredeem -> USDC");
        uint256 previewUSDC = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_USDC), shares);
        console.log("previewRedeem(USDC) -> %e", previewUSDC);
        uint256 backUSDC = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_USDC), 0, false);
        console.log("redeem(USDC) -> out = %e", backUSDC);
        _checkDust();
        vm.revertTo(snapshot);

        // REDEEM -> SIUSD
        console.log("\nredeem -> SIUSD");
        uint256 previewSIUSD = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_SIUSD), shares);
        console.log("previewRedeem(SIUSD) -> %e", previewSIUSD);
        uint256 backSI = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_SIUSD), 0, false);
        console.log("redeem(SIUSD) -> out = %e", backSI);
        _checkDust();
        vm.stopPrank();
    }

    // ───────────────────────── SIUSD deposit & all redeem paths ─────────────────────────

    function test_SIUSD_paths() public {
        deal(address(contract_USDC), user1, 300e6);
        uint256 siAmt = _prepareSIUSD(user1, 300e6);
        assertGt(siAmt, 0, "prepareSIUSD failed");

        vm.startPrank(user1);
        console.log("\n--- SIUSD PATH ---");

        // APPROVE
        contract_SIUSD.approve(address(contract_PendleInfinifiSIUSD), siAmt);

        // PREVIEW DEPOSIT
        uint256 previewSharesSIUSD = contract_PendleInfinifiSIUSD.previewDeposit(address(contract_SIUSD), siAmt);
        console.log("previewDeposit(SIUSD) -> %e", previewSharesSIUSD);

        // DEPOSIT
        uint256 shares = contract_PendleInfinifiSIUSD.deposit(user1, address(contract_SIUSD), siAmt, 0);
        console.log("deposit(SIUSD) -> shares = %e", shares);
        _checkDust();
        uint256 snapshot = vm.snapshot();

        // REDEEM -> SIUSD
        console.log("\nredeem -> SIUSD");
        uint256 previewSIUSD_Out = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_SIUSD), shares);
        console.log("previewRedeem(SIUSD) -> %e", previewSIUSD_Out);
        uint256 outSI = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_SIUSD), 0, false);
        console.log("redeem(SIUSD) -> out = %e", outSI);
        _checkDust();
        vm.revertTo(snapshot);

        // REDEEM -> IUSD
        console.log("\nredeem -> IUSD");
        uint256 previewIUSD = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_IUSD), shares);
        console.log("previewRedeem(IUSD) -> %e", previewIUSD);
        uint256 outI = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_IUSD), 0, false);
        console.log("redeem(IUSD) -> out = %e", outI);
        _checkDust();
        vm.revertTo(snapshot);

        // REDEEM -> USDC
        console.log("\nredeem -> USDC");
        uint256 previewUSDC = contract_PendleInfinifiSIUSD.previewRedeem(address(contract_USDC), shares);
        console.log("previewRedeem(USDC) -> %e", previewUSDC);
        uint256 outU = contract_PendleInfinifiSIUSD.redeem(user1, shares, address(contract_USDC), 0, false);
        console.log("redeem(USDC) -> out = %e", outU);
        _checkDust();
        vm.stopPrank();
    }
}

using PMath for uint256;

address public constant IUSD = 0x48f9e38f3070AD8945DFEae3FA70987722E3D89c;
address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address public constant SIUSD = 0xDBDC1Ef57537E34680B898E1FEBD3D68c7389bCB;
address public constant GATEWAY = 0x3f04b65Ddbd87f9CE0A2e7Eb24d80e7fb87625b5;

error UnsupportedToken(address _token);

constructor() PendleERC4626UpgSYV2(SIUSD) {}

function initialize(string memory _name, string memory _symbol) external override initializer {
__SYBaseUpg_init(_name, _symbol);
_safeApproveInf(IUSD, GATEWAY);
_safeApproveInf(USDC, GATEWAY);
_safeApproveInf(SIUSD, GATEWAY);
}

function _deposit(
address tokenIn,
uint256 amountDeposited
) internal override returns (uint256 /*amountSharesOut*/) {
if (tokenIn == IUSD) {
return IInfiniFiGateway(GATEWAY).stake(address(this), amountDeposited);
}

if (tokenIn == USDC) {
uint256 amountBefore = IERC4626(SIUSD).balanceOf(address(this));
IInfiniFiGateway(GATEWAY).mintAndStake(address(this), amountDeposited);
uint256 amountAfter = IERC4626(SIUSD).balanceOf(address(this));
return amountAfter - amountBefore;
}

if (tokenIn == SIUSD) {
return amountDeposited;
}

revert UnsupportedToken(tokenIn);
}

function _redeem(
address receiver,
address tokenOut,
uint256 amountSharesToRedeem
) internal override returns (uint256 /*amountTokenOut*/) {
if (tokenOut == IUSD) {
return IInfiniFiGateway(GATEWAY).unstake(receiver, amountSharesToRedeem);
}

if (tokenOut == USDC) {
// unstake from siUSD to iUSD
uint256 receiptOut = IInfiniFiGateway(GATEWAY).unstake(address(this), amountSharesToRedeem);
address redeemController = IInfiniFiGateway(GATEWAY).getAddress("redeemController");
// convert iUSD to USDC
uint256 assetsOut = IRedeemController(redeemController).receiptToAsset(receiptOut);
// redeem iUSD, assetsOut is in USDC
return IInfiniFiGateway(GATEWAY).redeem(receiver, receiptOut, assetsOut);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the InfiniFi protocol's redemption mechanism, when immediate liquidity is insufficient during RedeemController.redeem (called via InfiniFiGatewayV2.redeem), redemption requests are partially or fully enqueued in the RedemptionPool. Once the queue is funded (e.g., through subsequent deposits to the RedeemController), the allocated assets (USDC) are recorded in userPendingClaims[recipient] but are not automatically transferred to the user. Instead, the recipient must manually invoke RedeemController.claimRedemption (or via InfiniFiGatewayV2.claimRedemption) to receive the funds, as this function uses msg.sender to identify and transfer to the claimant, preventing delegation or third-party claims.

In the Pendle integration, PendleInfiniFisiUSD._redeem for tokenOut == USDC invokes InfiniFiGatewayV2.redeem(receiver, receiptOut, assetsOut), which returns only the immediately available assetsOut. If the redemption is queued (fully or partially), the pending portion remains in userPendingClaims for the specified receiver, and Pendle's redeem function completes without claiming or notifying about the outstanding amount. Users expecting atomic, full redemption (based on _previewRedeem estimates) receive only partial funds, requiring a separate transaction to claim the rest.
Relevant code from InfiniFiGatewayV2.claimRedemption:

function claimRedemption() external whenNotPaused nonReentrant {
    RedeemController(getAddress("redeemController")).claimRedemption(msg.sender);
}

From RedemptionPool._claimRedemption (called internally by RedeemController):

uint256 amount = userPendingClaims[_recipient];
require(amount > 0, NoPendingClaims(_recipient));

userPendingClaims[_recipient] = 0;
totalPendingClaims -= amount;

emit RedemptionClaimed(block.timestamp, _recipient, amount);
return amount;

(Note: The actual asset transfer occurs in RedeemController.claimRedemption after _claimRedemption, sending assetToken to the recipient)

This design ensures claims are secure and recipient-specific but introduces challenges:

  • Users must actively monitor for pending claims and execute an additional transaction, which may not be intuitive, especially if Pendle interfaces or integrators do not expose or automate this step.
  • If the receiver is a smart contract (e.g., a vault, wallet contract, or protocol integrating Pendle), it may lack built-in logic to call claimRedemption, potentially leaving funds stuck indefinitely unless the contract is upgraded or has fallback mechanisms.
  • In scenarios where the redeem caller differs from the receiver (e.g., approved spenders or delegated actions in Pendle), the caller cannot claim on behalf of the receiver, as msg.sender enforcement ties the action to the recipient's address.

The overall impact is that while funds are not permanently lost (they remain claimable), they could become inaccessible without manual intervention.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting assets out (usdc) to match the requested iUSD amount is going to prevent these requests from going into the queue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see what you mean:

    function redeem(address _to, uint256 _amount, uint256 _minAssetsOut)
        external
        whenNotPaused
        nonReentrant
        returns (uint256)
    {
        _revertIfThereAreUnaccruedLosses();
        ReceiptToken iusd = ReceiptToken(getAddress("receiptToken"));
        RedeemController redeemController = RedeemController(getAddress("redeemController"));

        iusd.transferFrom(msg.sender, address(this), _amount);
        iusd.approve(address(redeemController), _amount);
        uint256 assetsOut = redeemController.redeem(_to, _amount);
        require(assetsOut >= _minAssetsOut, MinAssetsOutError(_minAssetsOut, assetsOut)); // <--------------
        return assetsOut;
    }

Totally agree. Updated the issue here: #14

}

if (tokenOut == SIUSD) {
_transferOut(SIUSD, receiver, amountSharesToRedeem);
return amountSharesToRedeem;
}

revert UnsupportedToken(tokenOut);
}

// returns exchange rate in USDC
function exchangeRate() public view override returns (uint256) {
address redeemController = IInfiniFiGateway(GATEWAY).getAddress("redeemController");
uint256 receiptOut = _convertToAssets(PMath.ONE);
return IRedeemController(redeemController).receiptToAsset(receiptOut);
}

function _previewDeposit(
address tokenIn,
uint256 amountTokenToDeposit
) internal view override returns (uint256 /*amountSharesOut*/) {
if (tokenIn == IUSD) {
return _convertToShares(amountTokenToDeposit);
}

if (tokenIn == USDC) {
address mintController = IInfiniFiGateway(GATEWAY).getAddress("mintController");
// preview USDC to iUSD conversion
uint256 receiptTokens = IMintController(mintController).assetToReceipt(amountTokenToDeposit);
return _convertToShares(receiptTokens);
}

if (tokenIn == SIUSD) {
return amountTokenToDeposit;
}

revert UnsupportedToken(tokenIn);
}

function _previewRedeem(
address tokenOut,
uint256 amountSharesToRedeem
) internal view override returns (uint256 /*amountTokenOut*/) {
if (tokenOut == IUSD) {
return _convertToAssets(amountSharesToRedeem);
}

if (tokenOut == USDC) {
// see how much iUSD we get from redeeming siUSD
uint256 receiptOut = _convertToAssets(amountSharesToRedeem);
address redeemController = IInfiniFiGateway(GATEWAY).getAddress("redeemController");
// convert iUSD to USDC
return IRedeemController(redeemController).receiptToAsset(receiptOut);
}

if (tokenOut == SIUSD) {
return amountSharesToRedeem;
}

revert UnsupportedToken(tokenOut);
}

function _convertToShares(uint256 _receiptIn) internal view returns (uint256 /* _sharesOut */) {
uint256 vested = IYieldSharing(IInfiniFiGateway(GATEWAY).getAddress("yieldSharing")).vested();
uint256 supply = IERC4626(SIUSD).totalSupply();
uint256 assets = IERC4626(SIUSD).totalAssets() + vested;
return supply == 0 ? _receiptIn : ((_receiptIn * supply) / assets);
}

function _convertToAssets(uint256 _sharesIn) internal view returns (uint256 /* _assetsOut */) {
uint256 vested = IYieldSharing(IInfiniFiGateway(GATEWAY).getAddress("yieldSharing")).vested();
uint256 supply = IERC4626(SIUSD).totalSupply();
uint256 assets = IERC4626(SIUSD).totalAssets() + vested;
return supply == 0 ? _sharesIn : (_sharesIn * assets) / supply;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

siUSD vault has different rounding directions & virtual assets / shares (+1 every time, since _decimalOffset() is 0). Not sure if it's relevant here but wanted to point out

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lol. My LSP was jumping to Solmate implementation. I see now that this is open zeppelin version. Weird.

}

function getTokensIn() public pure override returns (address[] memory res) {
res = new address[](3);
res[0] = IUSD;
res[1] = SIUSD;
res[2] = USDC;
}

function getTokensOut() public pure override returns (address[] memory res) {
res = new address[](3);
res[0] = IUSD;
res[1] = SIUSD;
res[2] = USDC;
}

function isValidTokenIn(address token) public pure override returns (bool) {
return token == IUSD || token == SIUSD || token == USDC;
}

function isValidTokenOut(address token) public pure override returns (bool) {
return token == IUSD || token == SIUSD || token == USDC;
}

function assetInfo()
external
pure
override
returns (AssetType assetType, address assetAddress, uint8 assetDecimals)
{
return (AssetType.TOKEN, USDC, 6);
}
}