diff --git a/contracts/pendle-pt-fixed-rate-vault/PendlePTSlisBNBVaultAdapter.sol b/contracts/pendle-pt-fixed-rate-vault/PendlePTSlisBNBVaultAdapter.sol new file mode 100644 index 00000000..6801b90d --- /dev/null +++ b/contracts/pendle-pt-fixed-rate-vault/PendlePTSlisBNBVaultAdapter.sol @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.25; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { IPAllActionV3 } from "@pendle/core-v2/contracts/interfaces/IPAllActionV3.sol"; +import { TokenOutput } from "@pendle/core-v2/contracts/interfaces/IPAllActionTypeV3.sol"; +import { SwapData, SwapType } from "@pendle/core-v2/contracts/router/swap-aggregator/IPSwapAggregator.sol"; + +import { PendlePTVaultAdapter } from "./PendlePTVaultAdapter.sol"; +import { IPendlePTSlisBNBVaultAdapter } from "./interfaces/IPendlePTSlisBNBVaultAdapter.sol"; +import { IListaStakeManager } from "./interfaces/IListaStakeManager.sol"; + +/** + * @title PendlePTSlisBNBVaultAdapter + * @author Venus + * @notice slisBNB-specialized adapter that extends PendlePTVaultAdapter with a one-transaction + * withdraw → redeem → Lista unstake entrypoint and a permissionless claim that forwards + * native BNB back to the original owner. + * @dev Single-asset: slisBNB, the Lista StakeManager, and the unbond period are immutables. + * Works for any registered market whose PT redeems 1:1 to slisBNB (multiple maturities allowed). + * The base universal adapter is inherited unchanged; this child only adds its own storage + * (appended after the parent layout) and the unstake lifecycle. + */ +contract PendlePTSlisBNBVaultAdapter is PendlePTVaultAdapter, IPendlePTSlisBNBVaultAdapter { + using SafeERC20 for IERC20; + + // ═══════════════════════════════════════════════════════════════════════ + // IMMUTABLES + // ═══════════════════════════════════════════════════════════════════════ + + /// @notice slisBNB token — the PT redeem token and the token unstaked via Lista. + address public immutable SLIS_BNB; + + /// @notice Lista DAO StakeManager used for native (async) unstaking of slisBNB. + address public immutable LISTA_STAKE_MANAGER; + + /// @notice Unbond period estimate (seconds) used only to compute the off-chain `claimableAt` hint. + uint256 public immutable UNBOND_PERIOD; + + // ═══════════════════════════════════════════════════════════════════════ + // STATE VARIABLES + // ═══════════════════════════════════════════════════════════════════════ + + /// @dev Lista withdrawal request uuid → recorded request data (owner/amount/startTime). + /// Stored at request time so reads never depend on scanning Lista's live request array. + mapping(uint256 => UnstakeRequest) internal _unstakeRequests; + + /// @dev Owner → list of their outstanding unstake request uuids (compacted via swap-pop on claim). + mapping(address => uint256[]) internal _userUuids; + + /// @dev Reserved storage gap for future upgrades of the child (2 used slots + 48 = 50, matching the base). + uint256[48] private __gap; + + // ═══════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR + // ═══════════════════════════════════════════════════════════════════════ + + /// @notice Sets the immutable slisBNB token, Lista StakeManager, and unbond period estimate + /// @param pendleRouter_ Pendle Router (IPAllActionV3) address. + /// @param comptroller_ Venus core pool Comptroller address. + /// @param slisBnb_ slisBNB token address. + /// @param listaStakeManager_ Lista DAO StakeManager address. + /// @param unbondPeriod_ Unbond period estimate in seconds (e.g. 7 days). + constructor( + address pendleRouter_, + address comptroller_, + address slisBnb_, + address listaStakeManager_, + uint256 unbondPeriod_ + ) PendlePTVaultAdapter(pendleRouter_, comptroller_) { + if (slisBnb_ == address(0)) revert ZeroAddress(); + if (listaStakeManager_ == address(0)) revert ZeroAddress(); + if (unbondPeriod_ == 0) revert ZeroAmount(); + + SLIS_BNB = slisBnb_; + LISTA_STAKE_MANAGER = listaStakeManager_; + UNBOND_PERIOD = unbondPeriod_; + // Parent constructor already ran _disableInitializers(). + } + + // ═══════════════════════════════════════════════════════════════════════ + // CORE — UNSTAKE + // ═══════════════════════════════════════════════════════════════════════ + + /// @inheritdoc IPendlePTSlisBNBVaultAdapter + function requestWithdraw( + address pendleMarket, + uint256 vTokenAmount, + uint256 minSlisBnbOut + ) + external + whenNotPaused + nonReentrant + onlyRegisteredMarket(pendleMarket) + atOrAfterMaturity(pendleMarket) + returns (uint256 uuid) + { + if (vTokenAmount == 0) revert ZeroAmount(); + + MarketConfig memory config = markets[pendleMarket]; + address pt = config.pt; + + // 1. Redeem vTokens → adapter receives PT. + uint256 ptBefore = IERC20(pt).balanceOf(address(this)); + _redeemVTokens(config.vToken, vTokenAmount); + uint256 ptReceived = IERC20(pt).balanceOf(address(this)) - ptBefore; + + // 2. Redeem PT 1:1 → slisBNB to the adapter (so the adapter can drive the Lista unstake). + uint256 slisBnbAmount = _redeemPtToAdapter(pt, config.yt, ptReceived, minSlisBnbOut); + + // 3. Safety sweep of residual PT (not expected with exact-in redemption). + _sweepDust(pt, msg.sender, ptBefore); + + // 4. Enqueue the Lista unstake, record ownership, and emit (scoped to relieve the stack). + uuid = _enqueueUnstake(pendleMarket, vTokenAmount, ptReceived, slisBnbAmount); + } + + /// @inheritdoc IPendlePTSlisBNBVaultAdapter + function claimUnstaked(uint256 uuid) external nonReentrant { + // Intentionally NOT whenNotPaused so an adapter-level pause can never block a claim. Note this is not + // an unconditional guarantee: Lista's claimWithdraw is itself whenNotPaused and gated on bot + // confirmation, so a Lista-side pause or confirmation delay can still postpone the claim. + UnstakeRequest memory request = _unstakeRequests[uuid]; + address user = request.owner; + if (user == address(0)) revert UnstakeRequestNotFound(uuid); + + // Resolve the live index fresh — Lista compacts its array (swap-pop) on every claim, so the index + // is not stable across claims and must be looked up against Lista's current array each time. + IListaStakeManager.WithdrawalRequest[] memory requests = IListaStakeManager(LISTA_STAKE_MANAGER) + .getUserWithdrawalRequests(address(this)); + (bool found, uint256 idx) = _findRequestIndex(requests, uuid); + + // Effects before interactions (CEI). + delete _unstakeRequests[uuid]; + _removeUserUuid(user, uuid); + + uint256 bnbOut; + if (found) { + // Request still pending on the adapter: claim it now (BNB lands on the adapter via Lista). + uint256 balanceBefore = address(this).balance; + IListaStakeManager(LISTA_STAKE_MANAGER).claimWithdraw(idx); + bnbOut = address(this).balance - balanceBefore; + } else { + // The uuid left the adapter's Lista array, which is only possible if Lista's bot already claimed + // it via claimWithdrawFor (BOT-gated). Lista forwarded the BNB it locked at request time to this + // adapter, so forward that snapshot. Never recompute at claim — slisBNB appreciates, which would + // overpay from other requests' pooled BNB. + bnbOut = request.amountInBnb; + } + + Address.sendValue(payable(user), bnbOut); + + emit UnstakeClaimed(uuid, user, bnbOut); + } + + // ═══════════════════════════════════════════════════════════════════════ + // VIEW FUNCTIONS + // ═══════════════════════════════════════════════════════════════════════ + + /// @inheritdoc IPendlePTSlisBNBVaultAdapter + function getUserUuids(address user) external view returns (uint256[] memory) { + return _userUuids[user]; + } + + /// @inheritdoc IPendlePTSlisBNBVaultAdapter + function unstakeOwner(uint256 uuid) external view returns (address) { + return _unstakeRequests[uuid].owner; + } + + /// @inheritdoc IPendlePTSlisBNBVaultAdapter + function getUnstakeRequest( + uint256 uuid + ) external view returns (address user, uint256 amountInSnBnb, uint256 startTime, uint256 claimableAt) { + UnstakeRequest memory request = _unstakeRequests[uuid]; + user = request.owner; + amountInSnBnb = request.amountInSnBnb; + startTime = request.startTime; + // Off-chain estimate only; real claimability is reported by isClaimable(). + claimableAt = startTime == 0 ? 0 : startTime + UNBOND_PERIOD; + } + + /// @inheritdoc IPendlePTSlisBNBVaultAdapter + function isClaimable(uint256 uuid) external view returns (bool) { + if (_unstakeRequests[uuid].owner == address(0)) return false; + // Lista gates claims on its bot-advanced confirmation pointer, not on elapsed time. Every request + // the adapter creates is a post-upgrade ("new") request, claimable once uuid < nextConfirmedRequestUUID. + return uuid < IListaStakeManager(LISTA_STAKE_MANAGER).nextConfirmedRequestUUID(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // INTERNAL HELPERS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * @notice Hands slisBNB to Lista, records the request (owner/amount/startTime), and emits UnstakeRequested. + * @param pendleMarket Pendle market address (event context). + * @param vTokenAmount Amount of vTokens redeemed (event context). + * @param ptReceived Amount of PT redeemed (event context). + * @param slisBnbAmount Amount of slisBNB to unstake via Lista. + * @return uuid Lista withdrawal request identifier owned by msg.sender. + * @dev Split out of requestWithdraw to avoid stack-too-deep. + */ + function _enqueueUnstake( + address pendleMarket, + uint256 vTokenAmount, + uint256 ptReceived, + uint256 slisBnbAmount + ) internal returns (uint256 uuid) { + IERC20(SLIS_BNB).forceApprove(LISTA_STAKE_MANAGER, slisBnbAmount); + IListaStakeManager(LISTA_STAKE_MANAGER).requestWithdraw(slisBnbAmount); + IERC20(SLIS_BNB).forceApprove(LISTA_STAKE_MANAGER, 0); + + // The just-created request is the last element of the adapter's request array. Read it back and + // record its actual Lista-recorded fields, so later reads never have to re-scan Lista's array. + IListaStakeManager.WithdrawalRequest[] memory requests = IListaStakeManager(LISTA_STAKE_MANAGER) + .getUserWithdrawalRequests(address(this)); + uint256 idx = requests.length - 1; + IListaStakeManager.WithdrawalRequest memory request = requests[idx]; + uuid = request.uuid; + + // Snapshot the BNB Lista locked for this request now. Lista pays this fixed figure on claim (no + // claim-time recompute), so it is the exact amount to forward even if the bot later claims the + // request on the adapter's behalf via claimWithdrawFor (see claimUnstaked). + (, uint256 amountInBnb) = IListaStakeManager(LISTA_STAKE_MANAGER).getUserRequestStatus(address(this), idx); + + _unstakeRequests[uuid] = UnstakeRequest({ + owner: msg.sender, + amountInSnBnb: request.amountInSnBnb, + amountInBnb: amountInBnb, + startTime: request.startTime + }); + _userUuids[msg.sender].push(uuid); + + emit UnstakeRequested( + pendleMarket, + msg.sender, + uuid, + vTokenAmount, + ptReceived, + slisBnbAmount, + request.startTime, + request.startTime + UNBOND_PERIOD + ); + } + + /** + * @notice Redeems PT 1:1 to slisBNB held by this adapter (post-maturity). + * @param pt The Principal Token address to redeem. + * @param yt The Yield Token address required for redemption. + * @param ptBalance Amount of PT tokens to redeem. + * @param minSlisBnbOut Minimum slisBNB to receive (slippage protection). + * @return slisBnbAmount slisBNB delta received by the adapter. + * @dev Receiver is the adapter (not the user). Uses a balance delta rather than the router + * return value to be robust against unexpected router behavior. + * Approves the router for PT, redeems, then resets approval to zero. + */ + function _redeemPtToAdapter( + address pt, + address yt, + uint256 ptBalance, + uint256 minSlisBnbOut + ) internal returns (uint256 slisBnbAmount) { + TokenOutput memory output = TokenOutput({ + tokenOut: SLIS_BNB, + minTokenOut: minSlisBnbOut, + tokenRedeemSy: SLIS_BNB, + pendleSwap: address(0), + swapData: SwapData({ swapType: SwapType.NONE, extRouter: address(0), extCalldata: "", needScale: false }) + }); + + uint256 balanceBefore = IERC20(SLIS_BNB).balanceOf(address(this)); + + IERC20(pt).forceApprove(PENDLE_ROUTER, ptBalance); + IPAllActionV3(PENDLE_ROUTER).redeemPyToToken(address(this), yt, ptBalance, output); + IERC20(pt).forceApprove(PENDLE_ROUTER, 0); + + slisBnbAmount = IERC20(SLIS_BNB).balanceOf(address(this)) - balanceBefore; + } + + /** + * @notice Removes a uuid from a user's outstanding list via swap-pop. + * @param user The owner whose list to mutate. + * @param uuid The uuid to remove. + */ + function _removeUserUuid(address user, uint256 uuid) internal { + uint256[] storage uuids = _userUuids[user]; + uint256 length = uuids.length; + for (uint256 i; i < length; ++i) { + if (uuids[i] == uuid) { + uuids[i] = uuids[length - 1]; + uuids.pop(); + return; + } + } + } + + /** + * @notice Finds the index of a uuid within a withdrawal-request array. + * @param requests The adapter's live Lista withdrawal requests. + * @param uuid The uuid to locate. + * @return found True if a matching request is present (false once Lista has claimed/removed it). + * @return idx Index of the matching request (zero when not found). + * @dev Returns a flag instead of reverting so claimUnstaked can handle the bot-already-claimed case. + */ + function _findRequestIndex( + IListaStakeManager.WithdrawalRequest[] memory requests, + uint256 uuid + ) internal pure returns (bool found, uint256 idx) { + uint256 length = requests.length; + for (uint256 i; i < length; ++i) { + if (requests[i].uuid == uuid) { + return (true, i); + } + } + return (false, 0); + } +} diff --git a/contracts/pendle-pt-fixed-rate-vault/interfaces/IListaStakeManager.sol b/contracts/pendle-pt-fixed-rate-vault/interfaces/IListaStakeManager.sol new file mode 100644 index 00000000..a6156c01 --- /dev/null +++ b/contracts/pendle-pt-fixed-rate-vault/interfaces/IListaStakeManager.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.25; + +/** + * @title IListaStakeManager + * @author Venus + * @notice Minimal interface for the Lista DAO StakeManager used to natively unstake slisBNB into BNB. + * @dev Unstaking is asynchronous: `requestWithdraw` transfers in slisBNB and enqueues a request. The + * request does NOT become claimable purely by elapsed time — Lista gates `claimWithdraw` on its + * bot-advanced confirmation pointer `nextConfirmedRequestUUID` (a request is claimable once its + * `uuid < nextConfirmedRequestUUID`), which the bot advances only after undelegating and claiming the + * BNB from the beacon chain. The unbond period (~7 days) is therefore an estimate, not the gate. + * `claimWithdraw` sends the BNB to the caller and indexes into the caller's request array, which Lista + * compacts on claim (swap-and-pop), so indices are NOT stable across claims. + */ +interface IListaStakeManager { + /** + * @notice A pending unstake request belonging to an account. + * @param uuid Globally unique, stable identifier for the request (does not change as the array is compacted) + * @param amountInSnBnb Amount of slisBNB burned for this request + * @param startTime Timestamp when the request was created (unbond period is measured from here) + */ + struct WithdrawalRequest { + uint256 uuid; + uint256 amountInSnBnb; + uint256 startTime; + } + + /** + * @notice Burn slisBNB and enqueue a withdrawal request for the caller. + * @param _amountInSlisBnb Amount of slisBNB to unstake (must be approved to the StakeManager first). + */ + function requestWithdraw(uint256 _amountInSlisBnb) external; + + /** + * @notice Claim a matured withdrawal request, sending the unbonded BNB to the caller. + * @dev Reverts if the request at `_idx` has not completed the unbond period. + * `_idx` is the position in the caller's current request array, which Lista compacts on claim. + * @param _idx Index into the caller's withdrawal request array. + */ + function claimWithdraw(uint256 _idx) external; + + /** + * @notice Get all pending withdrawal requests for an account. + * @param _address The account whose requests to read. + * @return Array of pending withdrawal requests. + */ + function getUserWithdrawalRequests(address _address) external view returns (WithdrawalRequest[] memory); + + /** + * @notice Convert an amount of slisBNB to its BNB value at the current exchange rate. + * @param _amountInSlisBnb Amount of slisBNB. + * @return Equivalent BNB amount. + */ + function convertSnBnbToBnb(uint256 _amountInSlisBnb) external view returns (uint256); + + /** + * @notice Confirmation pointer below which "new" withdrawal requests are claimable. + * @dev A request is claimable once its `uuid < nextConfirmedRequestUUID`. Advanced only by Lista's bot + * (after beacon-chain undelegation), never by elapsed time. This is the real on-chain claim gate. + * @return The next-confirmed request uuid pointer. + */ + function nextConfirmedRequestUUID() external view returns (uint256); + + /** + * @notice Status of a specific withdrawal request, including the exact BNB it will pay out. + * @dev For a "new" (post-migration) request, `_amount` is the BNB figure Lista locked at request time + * (`convertSnBnbToBnb` at that block), not a claim-time recompute, so it is unaffected by later + * slisBNB appreciation. `_idx` is the position in the account's `getUserWithdrawalRequests` array. + * @param _user The account whose request to read. + * @param _idx Index into the account's withdrawal request array. + * @return _isClaimable Whether the request can be claimed now. + * @return _amount BNB amount the request will pay on claim (locked at request time for new requests). + */ + function getUserRequestStatus( + address _user, + uint256 _idx + ) external view returns (bool _isClaimable, uint256 _amount); +} diff --git a/contracts/pendle-pt-fixed-rate-vault/interfaces/IPendlePTSlisBNBVaultAdapter.sol b/contracts/pendle-pt-fixed-rate-vault/interfaces/IPendlePTSlisBNBVaultAdapter.sol new file mode 100644 index 00000000..fdfb4f6e --- /dev/null +++ b/contracts/pendle-pt-fixed-rate-vault/interfaces/IPendlePTSlisBNBVaultAdapter.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.25; + +import { IPendlePTVaultAdapter } from "./IPendlePTVaultAdapter.sol"; + +/** + * @title IPendlePTSlisBNBVaultAdapter + * @author Venus + * @notice Interface for the PendlePTSlisBNBVaultAdapter contract. + * @dev Extends the universal PendlePTVaultAdapter with a one-transaction + * withdraw → redeem → Lista unstake entrypoint (`requestWithdraw`) and a + * permissionless `claimUnstaked` that finalizes the async Lista unstake and + * forwards native BNB to the original owner. Inherits all base events/errors. + */ +interface IPendlePTSlisBNBVaultAdapter is IPendlePTVaultAdapter { + // ═══════════════════════════════════════════════════════════════════════ + // STRUCTS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * @notice Recorded data for a Lista unstake request the adapter enqueued. + * @param owner Address that requested the unstake and receives the unbonded BNB + * @param amountInSnBnb Amount of slisBNB burned for the request (snapshot at request time) + * @param amountInBnb BNB amount Lista locked for the request at request time (the exact claim payout) + * @param startTime Timestamp the Lista unbond clock started (request block timestamp) + */ + struct UnstakeRequest { + address owner; + uint256 amountInSnBnb; + uint256 amountInBnb; + uint256 startTime; + } + + // ═══════════════════════════════════════════════════════════════════════ + // EVENTS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * @notice Emitted when a user redeems a matured PT position and the adapter enqueues a Lista unstake. + * @param pendleMarket Pendle market address used for the redemption + * @param user Address of the user who requested the unstake (recorded as the uuid owner) + * @param uuid Lista withdrawal request identifier (stable across array compaction) + * @param vTokenAmount Amount of vTokens redeemed + * @param ptAmount Amount of PT tokens redeemed 1:1 via Pendle + * @param slisBnbAmount Amount of slisBNB sent to the Lista StakeManager for unstaking + * @param startTime Timestamp when the Lista unbond period started + * @param claimableAt Estimated timestamp when the request becomes claimable (startTime + UNBOND_PERIOD) + */ + event UnstakeRequested( + address indexed pendleMarket, + address indexed user, + uint256 indexed uuid, + uint256 vTokenAmount, + uint256 ptAmount, + uint256 slisBnbAmount, + uint256 startTime, + uint256 claimableAt + ); + + /** + * @notice Emitted when a matured Lista unstake request is claimed and BNB is forwarded to the owner. + * @param uuid Lista withdrawal request identifier that was claimed + * @param user Address of the original owner who receives the native BNB + * @param bnbAmount Amount of native BNB forwarded to the user (non-indexed value field, like base events) + */ + event UnstakeClaimed(uint256 indexed uuid, address indexed user, uint256 bnbAmount); // solhint-disable-line gas-indexed-events + + // ═══════════════════════════════════════════════════════════════════════ + // CUSTOM ERRORS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * @notice Error thrown when an unstake request for the given uuid is unknown or already claimed. + * @param uuid The Lista withdrawal request identifier that was not found + */ + error UnstakeRequestNotFound(uint256 uuid); + + // ═══════════════════════════════════════════════════════════════════════ + // CORE — UNSTAKE + // ═══════════════════════════════════════════════════════════════════════ + + /** + * @notice Redeem a matured PT position to slisBNB and enqueue a native Lista unstake in one transaction. + * @dev Flow: redeem vTokens → redeem PT 1:1 to slisBNB via Pendle → requestWithdraw on Lista. + * The adapter (not the user) calls Lista, so it records uuid → msg.sender for later claim. + * User must have delegated to this adapter in the Comptroller beforehand. + * @param pendleMarket Pendle market address (must redeem 1:1 to slisBNB) + * @param vTokenAmount Amount of vTokens to redeem + * @param minSlisBnbOut Minimum slisBNB to receive from the PT redemption (slippage protection) + * @return uuid Lista withdrawal request identifier owned by msg.sender + */ + function requestWithdraw( + address pendleMarket, + uint256 vTokenAmount, + uint256 minSlisBnbOut + ) external returns (uint256 uuid); + + /** + * @notice Finalize a confirmed Lista unstake request and forward native BNB to its owner. + * @dev Permissionless — anyone may call; BNB always goes to the recorded owner, never the caller. + * Not gated by the adapter pause, so an adapter-level pause can never block a claim. This is not an + * unconditional availability guarantee: the underlying Lista `claimWithdraw` is itself pausable and + * gated on bot confirmation, so a Lista-side pause or confirmation delay still postpones the claim. + * Reverts if `uuid` is unknown/already claimed, or if Lista has not yet confirmed the request + * (see `isClaimable`). + * @param uuid Lista withdrawal request identifier to claim + */ + function claimUnstaked(uint256 uuid) external; + + // ═══════════════════════════════════════════════════════════════════════ + // VIEW FUNCTIONS + // ═══════════════════════════════════════════════════════════════════════ + + /** + * @notice Get all outstanding Lista unstake request uuids for a user. + * @param user The user whose unstake requests to read + * @return Array of uuids owned by the user + */ + function getUserUuids(address user) external view returns (uint256[] memory); + + /** + * @notice Get details for a recorded unstake request. + * @dev Reads the adapter's own stored record (set at request time, cleared on claim); all fields are + * zero once the request is claimed or if the uuid is unknown. `claimableAt` is only an off-chain + * estimate (startTime + UNBOND_PERIOD); use `isClaimable` for the real on-chain claim gate. + * @param uuid Lista withdrawal request identifier + * @return user Recorded owner of the request + * @return amountInSnBnb Amount of slisBNB burned for the request + * @return startTime Timestamp when the unbond period started + * @return claimableAt Estimated timestamp when claimable (startTime + UNBOND_PERIOD); an estimate only + */ + function getUnstakeRequest( + uint256 uuid + ) external view returns (address user, uint256 amountInSnBnb, uint256 startTime, uint256 claimableAt); + + /** + * @notice Whether a recorded unstake request is claimable now, per Lista's on-chain confirmation gate. + * @dev Reflects Lista's real gate (uuid < nextConfirmedRequestUUID), not the elapsed-time estimate. + * Returns false for unknown/claimed uuids. A true result means `claimUnstaked` should succeed + * (barring a Lista-side pause); the `claimableAt` hint can differ from this in both directions. + * @param uuid Lista withdrawal request identifier + * @return True if Lista has confirmed the request and it can be claimed + */ + function isClaimable(uint256 uuid) external view returns (bool); + + /** + * @notice Recorded owner of a Lista unstake request uuid (zero if unknown or claimed). + * @param uuid Lista withdrawal request identifier + * @return Owner address recorded at request time + */ + function unstakeOwner(uint256 uuid) external view returns (address); + + /** + * @notice The slisBNB token (PT redeem token and unstake token). + * @return slisBNB token address + */ + function SLIS_BNB() external view returns (address); + + /** + * @notice The Lista DAO StakeManager used for native unstaking. + * @return Lista StakeManager address + */ + function LISTA_STAKE_MANAGER() external view returns (address); + + /** + * @notice The Lista unbond period estimate used to compute claimableAt. + * @return Unbond period in seconds + */ + function UNBOND_PERIOD() external view returns (uint256); +} diff --git a/tests/hardhat/Fork/pendlePTVaultAdapter/tests/PendlePTSlisBNBVaultAdapter.spec.ts b/tests/hardhat/Fork/pendlePTVaultAdapter/tests/PendlePTSlisBNBVaultAdapter.spec.ts new file mode 100644 index 00000000..ddbc118c --- /dev/null +++ b/tests/hardhat/Fork/pendlePTVaultAdapter/tests/PendlePTSlisBNBVaultAdapter.spec.ts @@ -0,0 +1,400 @@ +import "@nomicfoundation/hardhat-chai-matchers"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import chai from "chai"; +import { BigNumber } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { FORK_MAINNET, forking, initMainnetUser } from "../../utils"; +import { + COMPTROLLER, + FAKE_MARKET, + LISTA_STAKE_MANAGER, + LISTA_UNBOND_PERIOD, + PENDLE_ROUTER_V3, + SLISBNB, + SLISBNB_UNSTAKE_BLOCK, +} from "../utils/constants"; +import { forceConfirmListaRequest, forceGrantListaBot } from "../utils/listaUnstake"; +import { + seedVTokenPosition, + slisBaseFixture, + slisMaturedFixture, + slisRequestedFixture, +} from "../utils/slisBnbFixtures"; + +const { expect } = chai; + +function findEvent(receipt: any, name: string) { + return receipt.events?.find((e: any) => e.event === name); +} + +function describeTests() { + describe("PendlePTSlisBNBVaultAdapter - Unstake Lifecycle", () => { + // ── requestWithdraw ───────────────────────────────────────────────── + + describe("requestWithdraw", () => { + it("redeems the position, hands slisBNB to Lista, and records the request", async () => { + const { adapter, user, slisbnb, vToken, ptToken, lista, marketAddress, depositVTokenAmount } = + await loadFixture(slisMaturedFixture); + + const userVTokenBefore = await vToken.balanceOf(user.address); + + const tx = await adapter.connect(user).requestWithdraw(marketAddress, depositVTokenAmount, 0); + const receipt = await tx.wait(); + + // Event + const ev = findEvent(receipt, "UnstakeRequested"); + expect(ev, "UnstakeRequested not emitted").to.not.be.undefined; + const uuid: BigNumber = ev!.args!.uuid; + const slisBnbAmount: BigNumber = ev!.args!.slisBnbAmount; + expect(ev!.args!.pendleMarket).to.equal(marketAddress); + expect(ev!.args!.user).to.equal(user.address); + expect(slisBnbAmount).to.be.gt(0); + + // vTokens were redeemed from the user; adapter is left holding nothing + expect(userVTokenBefore.sub(await vToken.balanceOf(user.address))).to.equal(depositVTokenAmount); + expect(await slisbnb.balanceOf(adapter.address)).to.equal(0); + expect(await ptToken.balanceOf(adapter.address)).to.equal(0); + expect(await vToken.balanceOf(adapter.address)).to.equal(0); + + // Lista recorded the request for the ADAPTER (not the user) + const requests = await lista.getUserWithdrawalRequests(adapter.address); + const listaReq = requests.find((r: any) => r.uuid.eq(uuid)); + expect(listaReq, "Lista did not record the adapter's request").to.not.be.undefined; + expect(listaReq.amountInSnBnb).to.equal(slisBnbAmount); + + // Adapter ownership + snapshot bookkeeping + expect(await adapter.unstakeOwner(uuid)).to.equal(user.address); + expect(await adapter.getUserUuids(user.address)).to.deep.equal([uuid]); + + const record = await adapter.getUnstakeRequest(uuid); + expect(record.user).to.equal(user.address); + expect(record.amountInSnBnb).to.equal(slisBnbAmount); + expect(record.startTime).to.be.gt(0); + expect(record.claimableAt).to.equal(record.startTime.add(LISTA_UNBOND_PERIOD)); + + // Not yet confirmed by Lista's bot pointer + expect(await adapter.isClaimable(uuid)).to.be.false; + }); + + it("reverts with ZeroAmount when vTokenAmount is 0", async () => { + const { adapter, user, marketAddress } = await loadFixture(slisMaturedFixture); + await expect(adapter.connect(user).requestWithdraw(marketAddress, 0, 0)).to.be.revertedWithCustomError( + adapter, + "ZeroAmount", + ); + }); + + it("reverts with MarketNotRegistered for an unknown market", async () => { + const { adapter, user } = await loadFixture(slisBaseFixture); + await expect(adapter.connect(user).requestWithdraw(FAKE_MARKET, 1, 0)) + .to.be.revertedWithCustomError(adapter, "MarketNotRegistered") + .withArgs(FAKE_MARKET); + }); + + it("reverts when the adapter is paused", async () => { + const { adapter, owner, user, marketAddress } = await loadFixture(slisBaseFixture); + await adapter.connect(owner).pause(); + await expect(adapter.connect(user).requestWithdraw(marketAddress, 1, 0)).to.be.revertedWith("Pausable: paused"); + }); + + it("reverts when minSlisBnbOut exceeds the redeemable amount (slippage)", async () => { + const { adapter, user, marketAddress, depositVTokenAmount } = await loadFixture(slisMaturedFixture); + const absurdMin = parseUnits("1000000", 18); + await expect(adapter.connect(user).requestWithdraw(marketAddress, depositVTokenAmount, absurdMin)).to.be + .reverted; + }); + + it("reverts when the user has not delegated to the adapter", async () => { + const { adapter, user, comptroller, marketAddress, depositVTokenAmount } = + await loadFixture(slisMaturedFixture); + // Revoke the delegation the fixture set up, so the adapter's redeemBehalf is rejected. + await comptroller.connect(user).updateDelegate(adapter.address, false); + await expect(adapter.connect(user).requestWithdraw(marketAddress, depositVTokenAmount, 0)).to.be.reverted; + }); + }); + + // ── claimUnstaked ─────────────────────────────────────────────────── + + describe("claimUnstaked", () => { + it("reverts with UnstakeRequestNotFound for an unknown uuid", async () => { + const { adapter, user } = await loadFixture(slisRequestedFixture); + const unknown = 999999999; + await expect(adapter.connect(user).claimUnstaked(unknown)) + .to.be.revertedWithCustomError(adapter, "UnstakeRequestNotFound") + .withArgs(unknown); + }); + + it("reverts while Lista has not yet confirmed the request", async () => { + const { adapter, user, uuid } = await loadFixture(slisRequestedFixture); + // Request exists on the adapter's Lista array but uuid >= nextConfirmedRequestUUID, + // so Lista's claimWithdraw rejects it. + expect(await adapter.isClaimable(uuid)).to.be.false; + await expect(adapter.connect(user).claimUnstaked(uuid)).to.be.revertedWith("Not able to claim yet"); + }); + + it("pays the owner the exact locked BNB once confirmed, callable by anyone", async () => { + const { adapter, user, uuid, expectedBnb } = await loadFixture(slisRequestedFixture); + const [, claimer] = await ethers.getSigners(); + + await forceConfirmListaRequest(uuid); + expect(await adapter.isClaimable(uuid)).to.be.true; + + const ownerBnbBefore = await ethers.provider.getBalance(user.address); + + // A third party finalizes; BNB must still go to the recorded owner, never the caller. + const tx = await adapter.connect(claimer).claimUnstaked(uuid); + const receipt = await tx.wait(); + + expect((await ethers.provider.getBalance(user.address)).sub(ownerBnbBefore)).to.equal(expectedBnb); + expect(await ethers.provider.getBalance(adapter.address)).to.equal(0); + + // Record cleared + expect(await adapter.unstakeOwner(uuid)).to.equal(ethers.constants.AddressZero); + expect(await adapter.getUserUuids(user.address)).to.deep.equal([]); + expect(await adapter.isClaimable(uuid)).to.be.false; + + const cleared = await adapter.getUnstakeRequest(uuid); + expect(cleared.user).to.equal(ethers.constants.AddressZero); + expect(cleared.amountInSnBnb).to.equal(0); + expect(cleared.startTime).to.equal(0); + expect(cleared.claimableAt).to.equal(0); + + const ev = findEvent(receipt, "UnstakeClaimed"); + expect(ev, "UnstakeClaimed not emitted").to.not.be.undefined; + expect(ev!.args!.uuid).to.equal(uuid); + expect(ev!.args!.user).to.equal(user.address); + expect(ev!.args!.bnbAmount).to.equal(expectedBnb); + }); + + it("forwards the snapshot when Lista's bot already claimed via claimWithdrawFor (orphan path)", async () => { + const { adapter, user, uuid, idx, expectedBnb, lista } = await loadFixture(slisRequestedFixture); + const [, claimer, botSigner] = await ethers.getSigners(); + + await forceConfirmListaRequest(uuid); + await forceGrantListaBot(botSigner.address); + + // Bot claims on the adapter's behalf: Lista pays the ADAPTER the request-time-locked + // BNB and swap-pops the entry out of the adapter's request array. + const adapterBnbBefore = await ethers.provider.getBalance(adapter.address); + await lista.connect(botSigner).claimWithdrawFor(adapter.address, idx); + + expect((await ethers.provider.getBalance(adapter.address)).sub(adapterBnbBefore)).to.equal(expectedBnb); + const requestsAfter = await lista.getUserWithdrawalRequests(adapter.address); + expect( + requestsAfter.find((r: any) => r.uuid.eq(uuid)), + "uuid should have left Lista's array", + ).to.be.undefined; + + // Adapter record survives as an orphan: the uuid is gone from Lista but the owner is still recorded. + expect(await adapter.unstakeOwner(uuid)).to.equal(user.address); + + // claimUnstaked must detect the orphan and forward the pooled snapshot (the request-time-locked amount). + const ownerBnbBefore = await ethers.provider.getBalance(user.address); + const tx = await adapter.connect(claimer).claimUnstaked(uuid); + const receipt = await tx.wait(); + + expect((await ethers.provider.getBalance(user.address)).sub(ownerBnbBefore)).to.equal(expectedBnb); + expect(await ethers.provider.getBalance(adapter.address)).to.equal(0); + expect(await adapter.unstakeOwner(uuid)).to.equal(ethers.constants.AddressZero); + expect(await adapter.getUserUuids(user.address)).to.deep.equal([]); + + const ev = findEvent(receipt, "UnstakeClaimed"); + expect(ev!.args!.bnbAmount).to.equal(expectedBnb); + }); + + it("isolates pooled BNB across a mixed orphan + normal claim (no cross-contamination)", async () => { + const { adapter, user, lista, marketAddress, depositVTokenAmount } = await loadFixture(slisMaturedFixture); + const [, claimer, botSigner] = await ethers.getSigners(); + + const firstAmount = depositVTokenAmount.div(2); + const secondAmount = depositVTokenAmount.sub(firstAmount); + + const rA = await (await adapter.connect(user).requestWithdraw(marketAddress, firstAmount, 0)).wait(); + const rB = await (await adapter.connect(user).requestWithdraw(marketAddress, secondAmount, 0)).wait(); + const uuidA: BigNumber = findEvent(rA, "UnstakeRequested")!.args!.uuid; + const uuidB: BigNumber = findEvent(rB, "UnstakeRequested")!.args!.uuid; + + // Snapshot each request's locked BNB and Lista index before any claim. + const reqs = await lista.getUserWithdrawalRequests(adapter.address); + const idxA = reqs.findIndex((r: any) => r.uuid.eq(uuidA)); + const [, expectedA] = await lista.getUserRequestStatus(adapter.address, idxA); + const [, expectedB] = await lista.getUserRequestStatus( + adapter.address, + reqs.findIndex((r: any) => r.uuid.eq(uuidB)), + ); + + await forceConfirmListaRequest(uuidB); // uuidA < uuidB, so this confirms both + await forceGrantListaBot(botSigner.address); + + // 1. Bot claims A on the adapter's behalf: A's BNB is now POOLED on the adapter, A is orphaned. + const adapterBefore = await ethers.provider.getBalance(adapter.address); + await lista.connect(botSigner).claimWithdrawFor(adapter.address, idxA); + expect((await ethers.provider.getBalance(adapter.address)).sub(adapterBefore)).to.equal(expectedA); + expect(await adapter.unstakeOwner(uuidA)).to.equal(user.address); // orphan record survives + + // 2. Claim B through the NORMAL (found) path. balanceBefore now includes A's pooled BNB, so the + // balance delta must isolate B's payout and leave A's pool untouched. + const userBeforeB = await ethers.provider.getBalance(user.address); + await adapter.connect(claimer).claimUnstaked(uuidB); + expect((await ethers.provider.getBalance(user.address)).sub(userBeforeB)).to.equal(expectedB); + expect(await ethers.provider.getBalance(adapter.address)).to.equal(expectedA); // A's pool did not leak into B + + // 3. Claim A through the ORPHAN path: forwards exactly A's snapshot from the remaining pool. + const userBeforeA = await ethers.provider.getBalance(user.address); + await adapter.connect(claimer).claimUnstaked(uuidA); + expect((await ethers.provider.getBalance(user.address)).sub(userBeforeA)).to.equal(expectedA); + expect(await ethers.provider.getBalance(adapter.address)).to.equal(0); + expect(await adapter.getUserUuids(user.address)).to.deep.equal([]); + }); + + it("reverts on a second claim of the same uuid", async () => { + const { adapter, uuid } = await loadFixture(slisRequestedFixture); + const [, claimer] = await ethers.getSigners(); + + await forceConfirmListaRequest(uuid); + await adapter.connect(claimer).claimUnstaked(uuid); + + await expect(adapter.connect(claimer).claimUnstaked(uuid)) + .to.be.revertedWithCustomError(adapter, "UnstakeRequestNotFound") + .withArgs(uuid); + }); + + it("resolves the live index per claim when Lista compacts its array (multiple requests)", async () => { + const { adapter, user, lista, marketAddress, depositVTokenAmount } = await loadFixture(slisMaturedFixture); + const [, claimer] = await ethers.getSigners(); + + const firstAmount = depositVTokenAmount.div(2); + const secondAmount = depositVTokenAmount.sub(firstAmount); + + const rcpt1 = await (await adapter.connect(user).requestWithdraw(marketAddress, firstAmount, 0)).wait(); + const rcpt2 = await (await adapter.connect(user).requestWithdraw(marketAddress, secondAmount, 0)).wait(); + const uuidA: BigNumber = findEvent(rcpt1, "UnstakeRequested")!.args!.uuid; + const uuidB: BigNumber = findEvent(rcpt2, "UnstakeRequested")!.args!.uuid; + expect(uuidB).to.be.gt(uuidA); + + // Both queued under the adapter, both tracked for the user + expect(await adapter.getUserUuids(user.address)).to.deep.equal([uuidA, uuidB]); + + const requests = await lista.getUserWithdrawalRequests(adapter.address); + const idxA = requests.findIndex((r: any) => r.uuid.eq(uuidA)); + const idxB = requests.findIndex((r: any) => r.uuid.eq(uuidB)); + const [, expectedA] = await lista.getUserRequestStatus(adapter.address, idxA); + const [, expectedB] = await lista.getUserRequestStatus(adapter.address, idxB); + + // Confirm up to the later uuid (covers both). + await forceConfirmListaRequest(uuidB); + + // Claim A first. Lista swap-pops it, moving B to a different index; the adapter must + // re-resolve B's index on the next claim rather than reusing a stale one. + const ownerBefore = await ethers.provider.getBalance(user.address); + await adapter.connect(claimer).claimUnstaked(uuidA); + expect(await adapter.getUserUuids(user.address)).to.deep.equal([uuidB]); + + await adapter.connect(claimer).claimUnstaked(uuidB); + expect(await adapter.getUserUuids(user.address)).to.deep.equal([]); + + expect((await ethers.provider.getBalance(user.address)).sub(ownerBefore)).to.equal(expectedA.add(expectedB)); + expect(await ethers.provider.getBalance(adapter.address)).to.equal(0); + }); + + it("still pays out while the adapter is paused (claim is intentionally not pausable)", async () => { + const { adapter, owner, user, uuid, expectedBnb } = await loadFixture(slisRequestedFixture); + const [, claimer] = await ethers.getSigners(); + + await forceConfirmListaRequest(uuid); + await adapter.connect(owner).pause(); + + const ownerBnbBefore = await ethers.provider.getBalance(user.address); + await adapter.connect(claimer).claimUnstaked(uuid); + + expect((await ethers.provider.getBalance(user.address)).sub(ownerBnbBefore)).to.equal(expectedBnb); + expect(await adapter.unstakeOwner(uuid)).to.equal(ethers.constants.AddressZero); + }); + + it("routes each owner's BNB to the correct recipient with two distinct owners", async () => { + const base = await loadFixture(slisMaturedFixture); + const { adapter, user: user1, lista, marketAddress, depositVTokenAmount } = base; + const [, claimer] = await ethers.getSigners(); + // Use a clean impersonated EOA as the second owner: some hardhat default signer addresses + // collide with contracts deployed on the BSC fork, which would revert the native payout. + const user2 = await initMainnetUser("0x00000000000000000000000000000000000a11ce", parseUnits("10", 18)); + + // Seed a second, independent vToken position for user2. + const user2VTokens = await seedVTokenPosition(base, user2, parseUnits("8", 18)); + + const r1 = await (await adapter.connect(user1).requestWithdraw(marketAddress, depositVTokenAmount, 0)).wait(); + const r2 = await (await adapter.connect(user2).requestWithdraw(marketAddress, user2VTokens, 0)).wait(); + const uuid1: BigNumber = findEvent(r1, "UnstakeRequested")!.args!.uuid; + const uuid2: BigNumber = findEvent(r2, "UnstakeRequested")!.args!.uuid; + + expect(await adapter.unstakeOwner(uuid1)).to.equal(user1.address); + expect(await adapter.unstakeOwner(uuid2)).to.equal(user2.address); + + const requests = await lista.getUserWithdrawalRequests(adapter.address); + const [, expected1] = await lista.getUserRequestStatus( + adapter.address, + requests.findIndex((r: any) => r.uuid.eq(uuid1)), + ); + const [, expected2] = await lista.getUserRequestStatus( + adapter.address, + requests.findIndex((r: any) => r.uuid.eq(uuid2)), + ); + + await forceConfirmListaRequest(uuid2); // uuid1 < uuid2, so this confirms both + + const u1Before = await ethers.provider.getBalance(user1.address); + const u2Before = await ethers.provider.getBalance(user2.address); + await adapter.connect(claimer).claimUnstaked(uuid1); + await adapter.connect(claimer).claimUnstaked(uuid2); + + expect((await ethers.provider.getBalance(user1.address)).sub(u1Before)).to.equal(expected1); + expect((await ethers.provider.getBalance(user2.address)).sub(u2Before)).to.equal(expected2); + }); + }); + + // ── constructor & immutables ──────────────────────────────────────── + + describe("constructor & immutables", () => { + it("reverts on a zero slisBNB, Lista StakeManager, or unbond period", async () => { + const { adapter } = await loadFixture(slisBaseFixture); + const Factory = await ethers.getContractFactory("PendlePTSlisBNBVaultAdapter"); + + await expect( + Factory.deploy( + PENDLE_ROUTER_V3, + COMPTROLLER, + ethers.constants.AddressZero, + LISTA_STAKE_MANAGER, + LISTA_UNBOND_PERIOD, + ), + ).to.be.revertedWithCustomError(adapter, "ZeroAddress"); + await expect( + Factory.deploy(PENDLE_ROUTER_V3, COMPTROLLER, SLISBNB, ethers.constants.AddressZero, LISTA_UNBOND_PERIOD), + ).to.be.revertedWithCustomError(adapter, "ZeroAddress"); + await expect( + Factory.deploy(PENDLE_ROUTER_V3, COMPTROLLER, SLISBNB, LISTA_STAKE_MANAGER, 0), + ).to.be.revertedWithCustomError(adapter, "ZeroAmount"); + }); + + it("exposes the configured immutables", async () => { + const { adapter } = await loadFixture(slisBaseFixture); + expect(await adapter.SLIS_BNB()).to.equal(SLISBNB); + expect(await adapter.LISTA_STAKE_MANAGER()).to.equal(LISTA_STAKE_MANAGER); + expect(await adapter.UNBOND_PERIOD()).to.equal(LISTA_UNBOND_PERIOD); + }); + }); + }); +} + +// Standalone suite. Runs at a post-maturity block distinct from the shared (pre-maturity) +// BLOCK_NUMBER the index runner forks at, so it is intentionally NOT wired into index.spec.ts +// (the index uses a single forking() call; a second hardhat_reset would invalidate its snapshots). +// FORKED_NETWORK=bscmainnet npx hardhat test \ +// tests/hardhat/Fork/pendlePTVaultAdapter/tests/PendlePTSlisBNBVaultAdapter.spec.ts --network hardhat +if (FORK_MAINNET) { + forking(SLISBNB_UNSTAKE_BLOCK, () => { + describeTests(); + }); +} diff --git a/tests/hardhat/Fork/pendlePTVaultAdapter/utils/constants.ts b/tests/hardhat/Fork/pendlePTVaultAdapter/utils/constants.ts index 20944aeb..e29b12a5 100644 --- a/tests/hardhat/Fork/pendlePTVaultAdapter/utils/constants.ts +++ b/tests/hardhat/Fork/pendlePTVaultAdapter/utils/constants.ts @@ -3,6 +3,11 @@ export const BSC_CHAIN_ID = 56; export const BLOCK_NUMBER = 83040087; +// Dedicated block for the slisBNB unstake suite. Sits PAST the PT market's maturity (25-Jun-2026), +// the natural state for the redeem -> unstake -> claim flow. Kept separate from the shared +// (pre-maturity) BLOCK_NUMBER above, which the index runner and deposit/withdraw specs depend on. +export const SLISBNB_UNSTAKE_BLOCK = 107230482; + // Pendle ecosystem export const PENDLE_ROUTER_V3 = "0x888888888889758F76e7103c6CbF23ABbF58F946"; export const PENDLE_MARKET = "0x3C1a3D6B69A866444Fe506F7D38a00a1C2D859C5"; // PendleMarketV3 for PT-clisBNBx-25JUN2026 @@ -22,6 +27,10 @@ export const NORMAL_TIMELOCK = "0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396"; export const PANCAKE_ROUTER = "0x10ED43C718714eb63d5aA57B78B54704E256024E"; // PancakeSwap Router V2 export const LISTA_STAKE_MANAGER = "0x1adB950d8bB3dA4bE104211D5AB038628e477fE6"; // ListaDAO StakeManager +// Lista unbond-period estimate passed to the slisBNB adapter constructor. Only feeds the +// off-chain `claimableAt` hint; the real claim gate is Lista's nextConfirmedRequestUUID. +export const LISTA_UNBOND_PERIOD = 7 * 24 * 3600; // 7 days + // Lista DAO Oracle (used by DynamicDutyCalculator during SY deposit/redeem) export const LISTA_RESILIENT_ORACLE = "0xf3afD82A4071f272F403dC176916141f44E6c750"; export const LISTA_LISUSD = "0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5"; diff --git a/tests/hardhat/Fork/pendlePTVaultAdapter/utils/listaUnstake.ts b/tests/hardhat/Fork/pendlePTVaultAdapter/utils/listaUnstake.ts new file mode 100644 index 00000000..6c5521ac --- /dev/null +++ b/tests/hardhat/Fork/pendlePTVaultAdapter/utils/listaUnstake.ts @@ -0,0 +1,109 @@ +// Fork helpers for driving the REAL deployed Lista StakeManager through states that, +// on mainnet, only its off-chain bot can reach (beacon-chain undelegation + confirmation). +// +// Both helpers locate the storage slot they need empirically and VERIFY the result through +// the contract's own getter (`nextConfirmedRequestUUID()` / `hasRole`). A wrong slot is +// reverted and the scan continues, so a layout change fails loudly instead of silently +// mutating unrelated storage and testing nothing. +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; +import { BigNumber } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { LISTA_STAKE_MANAGER } from "./constants"; + +// Real Lista StakeManager surface used by the tests (read paths + the bot claim). +export const LISTA_MANAGER_ABI = [ + "function nextConfirmedRequestUUID() view returns (uint256)", + "function requestUUID() view returns (uint256)", + "function paused() view returns (bool)", + "function hasRole(bytes32 role, address account) view returns (bool)", + "function getUserWithdrawalRequests(address user) view returns (tuple(uint256 uuid, uint256 amountInSnBnb, uint256 startTime)[])", + "function getUserRequestStatus(address user, uint256 idx) view returns (bool isClaimable, uint256 amount)", + "function claimWithdrawFor(address user, uint256 idx) external", +]; + +// keccak256("BOT") — the role Lista requires for claimWithdrawFor. +const BOT_ROLE = ethers.utils.id("BOT"); + +// Upper bound for the slot scans. Lista's custom state sits ~slot 200 and AccessControl's +// `_roles` ~slot 100 behind the OZ upgradeable base gaps; 512 is comfortable headroom. +const MAX_SLOT_SCAN = 512; + +export async function getListaManager() { + return ethers.getContractAt(LISTA_MANAGER_ABI, LISTA_STAKE_MANAGER); +} + +/** + * Make a freshly-created Lista request claimable on the fork. + * + * On mainnet `nextConfirmedRequestUUID` only advances inside `claimUndelegated` (BOT-only, + * after real beacon-chain unbonding). We instead write the pointer directly: scan for the + * slot, set it to `uuid + 1`, and confirm via the public getter. We also top up the + * StakeManager's native balance so the subsequent payout is deterministic regardless of + * how much pooled BNB the fork block happens to hold (on mainnet the BNB is genuinely there). + */ +export async function forceConfirmListaRequest(uuid: BigNumber): Promise { + const lista = await getListaManager(); + const target = uuid.add(1); // request claimable once uuid < nextConfirmedRequestUUID + const current: BigNumber = await lista.nextConfirmedRequestUUID(); + + if (current.lt(target)) { + const targetHex = ethers.utils.hexZeroPad(target.toHexString(), 32); + let located = false; + + for (let slot = 0; slot < MAX_SLOT_SCAN; slot++) { + const raw = await ethers.provider.getStorageAt(LISTA_STAKE_MANAGER, slot); + // Only the pointer's own slot currently holds `current`; fingerprint on that value. + if (!BigNumber.from(raw).eq(current)) continue; + + const slotHex = ethers.utils.hexZeroPad(BigNumber.from(slot).toHexString(), 32); + await ethers.provider.send("hardhat_setStorageAt", [LISTA_STAKE_MANAGER, slotHex, targetHex]); + + if ((await lista.nextConfirmedRequestUUID()).eq(target)) { + located = true; + break; + } + // Wrong slot: undo and keep scanning. + await ethers.provider.send("hardhat_setStorageAt", [LISTA_STAKE_MANAGER, slotHex, raw]); + } + + if (!located) { + throw new Error("forceConfirmListaRequest: could not locate nextConfirmedRequestUUID slot"); + } + } + + const balance = await ethers.provider.getBalance(LISTA_STAKE_MANAGER); + await setBalance(LISTA_STAKE_MANAGER, balance.add(parseUnits("10000", 18))); +} + +/** + * Grant Lista's BOT role to `account` on the fork by writing `_roles[BOT].members[account]`. + * + * AccessControlUpgradeable lays out `RoleData { mapping(address=>bool) members; bytes32 adminRole; }` + * with `members` at offset 0, so the members mapping base == the RoleData slot. We scan the base + * slot of the `_roles` mapping, set the membership bit, and confirm via `hasRole`. Only the + * address-specific membership sub-slot is ever touched (and restored on a miss), so no unrelated + * state is corrupted. + */ +export async function forceGrantListaBot(account: string): Promise { + const lista = await getListaManager(); + if (await lista.hasRole(BOT_ROLE, account)) return; + + const coder = ethers.utils.defaultAbiCoder; + const ONE = ethers.utils.hexZeroPad("0x01", 32); + + for (let rolesSlot = 0; rolesSlot < MAX_SLOT_SCAN; rolesSlot++) { + const roleDataSlot = ethers.utils.keccak256(coder.encode(["bytes32", "uint256"], [BOT_ROLE, rolesSlot])); + const membersSlot = ethers.utils.keccak256(coder.encode(["address", "uint256"], [account, roleDataSlot])); + + const prev = await ethers.provider.getStorageAt(LISTA_STAKE_MANAGER, membersSlot); + await ethers.provider.send("hardhat_setStorageAt", [LISTA_STAKE_MANAGER, membersSlot, ONE]); + + if (await lista.hasRole(BOT_ROLE, account)) return; + + await ethers.provider.send("hardhat_setStorageAt", [LISTA_STAKE_MANAGER, membersSlot, prev]); + } + + throw new Error("forceGrantListaBot: could not locate AccessControl _roles slot"); +} diff --git a/tests/hardhat/Fork/pendlePTVaultAdapter/utils/slisBnbFixtures.ts b/tests/hardhat/Fork/pendlePTVaultAdapter/utils/slisBnbFixtures.ts new file mode 100644 index 00000000..172ca64e --- /dev/null +++ b/tests/hardhat/Fork/pendlePTVaultAdapter/utils/slisBnbFixtures.ts @@ -0,0 +1,288 @@ +// Fixtures for the slisBNB-specialized child adapter (PendlePTSlisBNBVaultAdapter). +// +// Deploys the CHILD (with its five constructor immutables) so the Lista unstake lifecycle +// (requestWithdraw / claimUnstaked) is exercised against the real deployed Lista StakeManager +// and Pendle router on a fork. +// +// The pinned fork block is already PAST the PT market's maturity, which is the natural state +// for unstaking: a user redeems a matured PT position and unstakes the slisBNB. Because PT can +// no longer be minted or swapped-into post-expiry, the vToken position is seeded by borrowing +// real PT from the market's AMM reserve and supplying it into Venus — fully on-chain, no +// time-sensitive Pendle hosted API. +import { impersonateAccount, setBalance } from "@nomicfoundation/hardhat-network-helpers"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { BigNumber, Contract } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { + ACCESS_CONTROL_MANAGER, + COMPTROLLER, + LISTA_STAKE_MANAGER, + LISTA_UNBOND_PERIOD, + NORMAL_TIMELOCK, + PENDLE_MARKET, + PENDLE_ROUTER_V3, + SLISBNB, + VTOKEN_PT_CLISBNBX_25JUN2026, + WBNB_WHALE, +} from "./constants"; +import { increaseListaOracleTimeDeltaTolerance, increaseVenusOracleMaxStalePeriod } from "./helpers"; +import { getListaManager } from "./listaUnstake"; + +// ── Fixture Return Types ──────────────────────────────────────────────── + +export interface SlisBaseFixture { + adapter: Contract; + owner: SignerWithAddress; + user: SignerWithAddress; + slisbnb: Contract; + vToken: Contract; + ptToken: Contract; + comptroller: Contract; + lista: Contract; + marketAddress: string; +} + +export interface SlisDepositedFixture extends SlisBaseFixture { + depositPtAmount: BigNumber; + depositVTokenAmount: BigNumber; +} + +export type SlisMaturedFixture = SlisDepositedFixture; + +export interface SlisRequestedFixture extends SlisMaturedFixture { + uuid: BigNumber; + idx: number; + // BNB Lista locked for the request at request time (== the adapter's amountInBnb snapshot, + // read back from Lista since the adapter does not expose it). + expectedBnb: BigNumber; + amountInSnBnb: BigNumber; +} + +// ── Market registration (post-maturity) ───────────────────────────────── +// +// addMarket() refuses an already-matured market (MarketAlreadyMatured) and Pendle's expiry() +// is immutable, so at a post-maturity fork the market that was registered on mainnet pre-maturity +// is reproduced by writing the adapter's `markets` mapping directly. The token addresses and +// maturity are read from the real Pendle market; the `markets` slot is located by fingerprint and +// every write is confirmed through the contract's own getters (a layout shift fails loudly). + +const slotHex = (v: BigNumber | number) => ethers.utils.hexZeroPad(BigNumber.from(v).toHexString(), 32); +const wordOf = (v: BigNumber | number | string) => ethers.utils.hexZeroPad(BigNumber.from(v).toHexString(), 32); +const addrWord = (a: string) => ethers.utils.hexZeroPad(a, 32); + +async function forceRegisterMarket(adapter: Contract, market: string, vToken: string): Promise { + const ipMarket = await ethers.getContractAt( + [ + "function readTokens() view returns (address SY, address PT, address YT)", + "function expiry() view returns (uint256)", + ], + market, + ); + const [sy, pt, yt] = await ipMarket.readTokens(); + const maturity: BigNumber = await ipMarket.expiry(); + + const coder = ethers.utils.defaultAbiCoder; + + // Locate the `markets` mapping slot: write the pt field at keccak(market, n) and confirm via the + // real markets(market).pt getter; restore on a miss so no unrelated storage is left mutated. + let base: BigNumber | null = null; + let mappingSlot = 0; + for (let n = 0; n < 384; n++) { + const candidate = BigNumber.from(ethers.utils.keccak256(coder.encode(["address", "uint256"], [market, n]))); + const prev = await ethers.provider.getStorageAt(adapter.address, slotHex(candidate)); + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(candidate), addrWord(pt)]); + if ((await adapter.markets(market)).pt.toLowerCase() === pt.toLowerCase()) { + base = candidate; + mappingSlot = n; + break; + } + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(candidate), prev]); + } + if (base === null) throw new Error("forceRegisterMarket: could not locate markets mapping slot"); + + // Struct fields: pt(+0, already set), sy(+1), yt(+2), vToken(+3), maturity(+4). + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(base.add(1)), addrWord(sy)]); + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(base.add(2)), addrWord(yt)]); + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(base.add(3)), addrWord(vToken)]); + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(base.add(4)), wordOf(maturity)]); + + // marketList is declared immediately after markets => slot (mappingSlot + 1): length 1, element[0]=market. + const listSlot = mappingSlot + 1; + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(listSlot), wordOf(1)]); + const elem0 = BigNumber.from(ethers.utils.keccak256(slotHex(listSlot))); + await ethers.provider.send("hardhat_setStorageAt", [adapter.address, slotHex(elem0), addrWord(market)]); + + // Confirm the whole registration through the public getters. + const cfg = await adapter.markets(market); + const all: string[] = await adapter.getAllMarkets(); + const ok = + cfg.pt.toLowerCase() === pt.toLowerCase() && + cfg.sy.toLowerCase() === sy.toLowerCase() && + cfg.yt.toLowerCase() === yt.toLowerCase() && + cfg.vToken.toLowerCase() === vToken.toLowerCase() && + cfg.maturity.eq(maturity) && + all.length === 1 && + all[0].toLowerCase() === market.toLowerCase(); + if (!ok) throw new Error("forceRegisterMarket: registration verification failed"); +} + +// ── Base Fixture ──────────────────────────────────────────────────────── +// +// Deploys the child adapter behind TransparentUpgradeableProxy and registers the +// PT-clisBNBx-25JUN2026 market (via storage, since the fork block is post-maturity). No deposit yet. + +export async function slisBaseFixture(): Promise { + const [owner] = await ethers.getSigners(); + + // Impersonate whale as user (just needs gas; the vToken position is seeded below) + await impersonateAccount(WBNB_WHALE); + await setBalance(WBNB_WHALE, parseUnits("100", 18)); + const user = await ethers.getSigner(WBNB_WHALE); + + const slisbnb = await ethers.getContractAt("IERC20", SLISBNB); + const vToken = await ethers.getContractAt("IVenusVToken", VTOKEN_PT_CLISBNBX_25JUN2026); + const comptroller = await ethers.getContractAt("IMarketFacet", COMPTROLLER); + const lista = await getListaManager(); + + // Use the mainnet ACM — NORMAL_TIMELOCK holds DEFAULT_ADMIN_ROLE + const acm = await ethers.getContractAt( + ["function giveCallPermission(address, string, address) external"], + ACCESS_CONTROL_MANAGER, + ); + await impersonateAccount(NORMAL_TIMELOCK); + await setBalance(NORMAL_TIMELOCK, parseUnits("1", 18)); + const timelockSigner = await ethers.getSigner(NORMAL_TIMELOCK); + + // Deploy implementation with the child's five constructor immutables + const Factory = await ethers.getContractFactory("PendlePTSlisBNBVaultAdapter"); + const implementation = await Factory.deploy( + PENDLE_ROUTER_V3, + COMPTROLLER, + SLISBNB, + LISTA_STAKE_MANAGER, + LISTA_UNBOND_PERIOD, + ); + await implementation.deployed(); + + // Deploy proxy + const proxyAdminAddress = "0x0000000000000000000000000000000000000001"; + const data = implementation.interface.encodeFunctionData("initialize", [ACCESS_CONTROL_MANAGER]); + const TransparentUpgradeableProxy = await ethers.getContractFactory( + "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol:TransparentUpgradeableProxy", + ); + const proxy = await TransparentUpgradeableProxy.deploy(implementation.address, proxyAdminAddress, data); + await proxy.deployed(); + + const adapter = await ethers.getContractAt("PendlePTSlisBNBVaultAdapter", proxy.address); + + // Grant ACM permissions to the owner via impersonated NORMAL_TIMELOCK (holds DEFAULT_ADMIN_ROLE). + // requestWithdraw / claimUnstaked are not access-controlled, so only the inherited admin + // functions need a grant. + const acmGuardedFunctions = ["addMarket(address,address)", "pause()", "unpause()"]; + for (const funcSig of acmGuardedFunctions) { + await acm.connect(timelockSigner).giveCallPermission(adapter.address, funcSig, owner.address); + } + + await forceRegisterMarket(adapter, PENDLE_MARKET, VTOKEN_PT_CLISBNBX_25JUN2026); + + const marketConfig = await adapter.markets(PENDLE_MARKET); + const ptToken = await ethers.getContractAt("IERC20", marketConfig.pt); + + return { + adapter, + owner, + user, + slisbnb, + vToken, + ptToken, + comptroller, + lista, + marketAddress: PENDLE_MARKET, + }; +} + +// ── vToken seeding ────────────────────────────────────────────────────── +// +// Creates a real Venus vToken position for `account`. Post-expiry PT can't be minted or swapped +// into, so PT is borrowed from the market's AMM reserve (harmless on a fork; no AMM invariant is +// asserted) and supplied into Venus via the real mint. Leaves the account delegated to the adapter +// so it can redeemBehalf during requestWithdraw. Returns the vTokens minted. + +export async function seedVTokenPosition( + base: { adapter: Contract; ptToken: Contract; vToken: Contract; comptroller: Contract }, + account: SignerWithAddress, + seedPt: BigNumber, +): Promise { + await impersonateAccount(PENDLE_MARKET); + await setBalance(PENDLE_MARKET, parseUnits("1", 18)); + const marketSigner = await ethers.getSigner(PENDLE_MARKET); + await base.ptToken.connect(marketSigner).transfer(account.address, seedPt); + + const vTokenMintable = await ethers.getContractAt(["function mint(uint256) returns (uint256)"], base.vToken.address); + await base.ptToken.connect(account).approve(base.vToken.address, seedPt); + const before = await base.vToken.balanceOf(account.address); + await vTokenMintable.connect(account).mint(seedPt); + const minted = (await base.vToken.balanceOf(account.address)).sub(before); + if (minted.isZero()) throw new Error("seedVTokenPosition: vToken mint produced 0 vTokens"); + + await base.comptroller.connect(account).updateDelegate(base.adapter.address, true); + return minted; +} + +// ── Deposited Fixture ─────────────────────────────────────────────────── +// +// Extends slisBaseFixture: seeds the user with a Venus vToken position backed by real PT and +// relaxes oracle staleness so the redeem holds up over a long fork run. + +export async function slisDepositedFixture(): Promise { + const base = await slisBaseFixture(); + const depositPtAmount = parseUnits("10", 18); + const depositVTokenAmount = await seedVTokenPosition(base, base.user, depositPtAmount); + + // Widen oracle staleness tolerance (both Venus and Lista). Over a long fork run, newly mined + // blocks drift ahead of the slisBNB/BNB feed timestamps; without this the redeem's resilient-oracle + // price reads start reverting ("invalid resilient oracle price") once a test runs late enough. + await increaseVenusOracleMaxStalePeriod(); + await increaseListaOracleTimeDeltaTolerance(); + + return { ...base, depositPtAmount, depositVTokenAmount }; +} + +// ── Matured Fixture ───────────────────────────────────────────────────── +// +// The pinned fork block is already past PT maturity, so the seeded position is matured as-is; +// this alias keeps the test intent explicit. + +export async function slisMaturedFixture(): Promise { + return slisDepositedFixture(); +} + +// ── Requested Fixture ─────────────────────────────────────────────────── +// +// Extends slisMaturedFixture: redeems the full vToken position and enqueues a single Lista +// unstake. The request is NOT yet confirmed (claims still revert) — claim tests drive +// confirmation themselves via forceConfirmListaRequest. + +export async function slisRequestedFixture(): Promise { + const matured = await slisMaturedFixture(); + + const tx = await matured.adapter.connect(matured.user).requestWithdraw( + matured.marketAddress, + matured.depositVTokenAmount, + 0, // minSlisBnbOut: post-maturity PT->slisBNB is 1:1; assertions check the actual amount + ); + const receipt = await tx.wait(); + + const requestedEvent = receipt.events?.find((e: any) => e.event === "UnstakeRequested"); + const uuid: BigNumber = requestedEvent!.args!.uuid; + + // Resolve the adapter's index in Lista's request array and snapshot the locked BNB. + const requests = await matured.lista.getUserWithdrawalRequests(matured.adapter.address); + const idx = requests.findIndex((r: any) => r.uuid.eq(uuid)); + const [, expectedBnb] = await matured.lista.getUserRequestStatus(matured.adapter.address, idx); + const amountInSnBnb: BigNumber = requests[idx].amountInSnBnb; + + return { ...matured, uuid, idx, expectedBnb, amountInSnBnb }; +}