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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions src/facets/strategies/EthenaStrategy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol";

import { LibDiamond } from "../../libraries/LibDiamond.sol";
import { IStakedUSDEV2 } from "../../interfaces/external/IStakedUSDEV2.sol";
import { ICurvePool } from "../../interfaces/external/ICurvePool.sol";

contract EthenaStrategyFacet {
using SafeERC20 for IERC20;

/*====errors====*/

error EthenaUSDENotConfigured();
error EthenaSlippageExceeded(uint256 minOut, uint256 received);
error EthenaCoinNotInPool(address tokenIn, address tokenOut);
error EthenaZeroAddress();

/*====events====*/

event EthenaSetConfig(address indexed usde, address indexed curvePool, address indexed stakedUSDEV2);

// erc7201:vaultrouter.strategy.ethena
bytes32 internal constant ETHENA_STORAGE_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000000;

struct EthenaStorage {
IERC20 usde;
ICurvePool curvePool;
IStakedUSDEV2 stakedUSDEV2;
uint256 maxSlippageBps;
uint256 cooldownDuration;
/*====only for offchain explicit routing ====*/
//mapping (address => bool) allowedRouter;
//mapping (address => bool) allowedSpender;
}

function _es() internal pure returns (EthenaStorage storage s) {
bytes32 slot = ETHENA_STORAGE_SLOT;
assembly {
s.slot := slot
}
}

function ethenaSetConfig(
IERC20 usde,
ICurvePool curvePool,
IStakedUSDEV2 stakedUSDEV2,
uint256 maxSlippageBps
)
external
{
LibDiamond.enforceIsContractOwner();
if (address(usde) == address(0) || address(curvePool) == address(0) || address(stakedUSDEV2) == address(0)) {
revert EthenaUSDENotConfigured();
}
EthenaStorage storage s = _es();
s.usde = usde;
s.curvePool = curvePool;
s.stakedUSDEV2 = stakedUSDEV2;
s.maxSlippageBps = maxSlippageBps;
emit EthenaSetConfig(address(usde), address(curvePool), address(stakedUSDEV2));
}

/*====IEthenaStrategy surface====*/

/// @notice Swap `amount` of the vault's underlying (USDC) into USDe and stake
/// it into sUSDe. Driven by the allocator's rebalance with a computed
/// delta, so it deposits exactly `amount` — not the whole balance.
function ethenaDeposit(uint256 amount) external {
EthenaStorage storage s = _es();
if (
address(s.usde) == address(0) || address(s.curvePool) == address(0) || address(s.stakedUSDEV2) == address(0)
) {
revert EthenaUSDENotConfigured();
}
if (amount == 0) return;

address underlying = IERC4626(address(this)).asset(); // USDC (6dec)

// Par-anchored floor: USDC(6dec) -> USDe(18dec), discounted by slippage.
uint256 minUsde = amount * 1e12 * (10_000 - s.maxSlippageBps) / 10_000;

// 1. USDC -> USDe on Curve (slippage enforced inside _swap).
uint256 usde = _swap(underlying, address(s.usde), amount, minUsde);

// 2. Stake USDe -> sUSDe.
s.usde.forceApprove(address(s.stakedUSDEV2), usde);
s.stakedUSDEV2.deposit(usde, address(this));
}

/// @notice Position value in underlying (USDC) terms. The facet holds sUSDe,
/// not USDe — value the shares at par and scale 18dec -> 6dec.
function ethenaTotalAssets() external view returns (uint256) {
EthenaStorage storage s = _es();
if (address(s.stakedUSDEV2) == address(0)) revert EthenaZeroAddress();
uint256 shares = s.stakedUSDEV2.balanceOf(address(this));
if (shares == 0) revert EthenaZeroAddress();
return s.stakedUSDEV2.convertToAssets(shares) / 1e12; // USDe(18) -> USDC(6)
}

function ethenaWithdraw() external {

//withdraw is gated on the core protocol side
//going asynchronous has lots of variables included and is practically complex

}

/*=== cooldown ===*/

/*==== internal swap helper — Curve only, par-floor enforced by caller ====*/

/// @dev Executes tokenIn -> tokenOut on the configured Curve StableSwap pool.
/// `minOut` is the caller-supplied par-anchored floor; this function is
/// pure execution and holds no slippage policy of its own. Returns the
/// amount of tokenOut actually received, measured by balance delta so we
/// never trust the pool's return value. Internal: the only callers are
/// ethenaDeposit / ethenaWithdraw, both reached via the gated rebalance.
function _swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minOut
)
internal
returns (uint256 received)
{
if (amountIn == 0) return 0;
ICurvePool pool = _es().curvePool;
if (address(pool) == address(0)) revert EthenaUSDENotConfigured();

(int128 i, int128 j) = _coinIndices(pool, tokenIn, tokenOut);

uint256 balBefore = IERC20(tokenOut).balanceOf(address(this));

IERC20(tokenIn).forceApprove(address(pool), amountIn);
pool.exchange(i, j, amountIn, minOut, address(this));

received = IERC20(tokenOut).balanceOf(address(this)) - balBefore;
if (received < minOut) revert EthenaSlippageExceeded(minOut, received);
}

/// @dev Resolve Curve coin indices for a token pair by scanning `coins()`.
/// Curve's DynArray getter reverts past the last index, so we try/catch
/// and stop at the first out-of-range read. Pools here hold ≤ 8 coins.
function _coinIndices(
ICurvePool pool,
address tokenIn,
address tokenOut
)
internal
view
returns (int128 inIdx, int128 outIdx)
{
bool foundIn;
bool foundOut;
for (uint256 k; k < 8; k++) {
try pool.coins(k) returns (address c) {
if (c == tokenIn) {
inIdx = int128(uint128(k));
foundIn = true;
}
if (c == tokenOut) {
outIdx = int128(uint128(k));
foundOut = true;
}
} catch {
break;
}
}
if (!foundIn || !foundOut) revert EthenaCoinNotInPool(tokenIn, tokenOut);
}
}
25 changes: 25 additions & 0 deletions src/interfaces/external/ICurvePool.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

interface ICurvePool {
function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy, address receiver) external returns (uint256);
function exchange_received(
int128 i,
int128 j,
uint256 _dx,
uint256 _min_dy,
address _receiver
)
external
returns (uint256);

function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256);
function get_dx(int128 i, int128 j, uint256 dy) external view returns (uint256);

// metadata
function coins(uint256 i) external view returns (address);
function balances(uint256 i) external view returns (uint256);
function A() external view returns (uint256);
function fee() external view returns (uint256);
}
21 changes: 21 additions & 0 deletions src/interfaces/external/IStakedUSDEV2.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

interface IStakedUSDEV2 {
function asset() external view returns (address); // == USDe
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function convertToAssets(uint256 shares) external view returns (uint256 assets);
function balanceOf(address account) external view returns (uint256);

// ERC-4626 instant exit — valid ONLY when cooldownDuration == 0
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
function maxRedeem(address owner) external view returns (uint256);

// Cooldown exit — valid ONLY when cooldownDuration > 0
function cooldownDuration() external view returns (uint24);
function cooldownShares(uint256 shares) external returns (uint256 assets);
function cooldownAssets(uint256 assets) external returns (uint256 shares);
function unstake(address receiver) external;
function cooldowns(address account) external view returns (uint104 cooldownEnd, uint152 underlyingAmount);
}