ERC4626 adapter for siUSD#9
Conversation
| } | ||
|
|
||
| function exchangeRate() public view virtual override returns (uint256) { | ||
| return IERC4626(yieldToken).convertToAssets(PMath.ONE); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Setting assets out (usdc) to match the requested iUSD amount is going to prevent these requests from going into the queue
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Lol. My LSP was jumping to Solmate implementation. I see now that this is open zeppelin version. Weird.

Adds infiniFi siUSD SY adapter as an ERC4626.