Skip to content

ERC4626 adapter for siUSD#9

Open
nikollamalic wants to merge 6 commits intopendle-finance:mainfrom
InfiniFi-Labs:infinifi-siusd
Open

ERC4626 adapter for siUSD#9
nikollamalic wants to merge 6 commits intopendle-finance:mainfrom
InfiniFi-Labs:infinifi-siusd

Conversation

@nikollamalic
Copy link
Copy Markdown

@nikollamalic nikollamalic commented Jul 2, 2025

Adds infiniFi siUSD SY adapter as an ERC4626.

@nikollamalic nikollamalic marked this pull request as ready for review July 3, 2025 07:57
Comment thread contracts/core/StandardizedYield/implementations/InfiniFi/PendleInfiniFisiUSD.sol Outdated
}

function exchangeRate() public view virtual override returns (uint256) {
return IERC4626(yieldToken).convertToAssets(PMath.ONE);
Copy link
Copy Markdown
Author

@nikollamalic nikollamalic Jul 11, 2025

Choose a reason for hiding this comment

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

Change to convert to USDC. Discuss if we actually need this, as in event of slash that might impact the iUSD peg, siUSD would be wiped out

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes, given that the iUSD price could be slashed and become lower than the USDC price, perhaps it would be better to reflect this in USDC directly. That would also require a change in the assetInfo function: return (AssetType.TOKEN, IUSD, 18);. Merely a suggestion, to help integrators. Current implementation is correct.

Comment on lines +86 to +127
function _previewDeposit(address tokenIn, uint256 amountTokenToDeposit)
internal
view
override
returns (uint256 /*amountSharesOut*/ )
{
if (tokenIn == IUSD) {
// preview iUSD to siUSD conversion
return IERC4626(SIUSD).previewDeposit(amountTokenToDeposit);
}

if (tokenIn == USDC) {
address mintController = IInfiniFiGateway(GATEWAY).getAddress("mintController");
// preview USDC to iUSD conversion
uint256 receiptOut = IMintController(mintController).assetToReceipt(amountTokenToDeposit);
// preview iUSD to siUSD conversion
return IERC4626(SIUSD).previewDeposit(receiptOut);
}

return amountTokenToDeposit;
}

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

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

return amountSharesToRedeem;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ERC‑4626 requires the preview helpers to provide a quote that is “as close to and no more than the exact result of the real deposit or redeem executed in the same transaction.” The Pendle InfiniFi SY breaks this guarantee as yieldSharing.distributeInterpolationRewards is not taken into account in the preview calculations.

Both stake and mintAndStake inside InfiniFiGatewayV2 begin by calling yieldSharing.distributeInterpolationRewards. That function realizes pending yield for every SIUSD holder and, crucially, mutates the vault’s accounting variables before the SY’s own balance‑changing code executes. In contrast, previewDeposit and previewRedeem are pure view calls: they bypass the gateway and read the pre‑distribution state. The up‑to‑date exchange‑rate that will exist after the first real deposit or redeem in a block is therefore invisible to the previews.

The observable consequence is that an integrator calling previewDeposit(token, amount) receives a quote that can be off by the full size of the reward distribution. Every time rewards accrue between two user interactions, the first state‑changing call will settle them and invalidate all cached previews observed since the previous settlement, violating ERC‑4626 section previewDeposit / previewRedeem.

// 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

return IRedeemController(redeemController).receiptToAsset(receiptOut);
}

return amountSharesToRedeem;
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 _redeem(), there's no handling for tokenOut == SIUSD case. The function returns amountTokenOut (which is uninitialized, defaulting to 0) without transferring any tokens. However, isValidTokenOut() returns true for SIUSD, meaning users can initiate redemptions for SIUSD that will always return 0 tokens.

Impact: Users redeeming to SIUSD will receive zero tokens while their shares are burned, resulting in permanent loss of funds.

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();
    }
}

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants