Skip to content
Draft
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
207 changes: 71 additions & 136 deletions contracts/rewards/SendEarnRewards.sol
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

// SendEarnRewards: ERC4626-compatible rewards adapter that routes
// deposits/withdraws into allowed SendEarn ERC4626 vaults (factory-gated),
// using the same "super" hook pattern as SendEarn to bubble calls into the
// underlying vault. Positions remain non-transferable (shares only mint/burn).
// Existing SendEarn users can "join" by calling depositAssets(vault, 0)
// to set their preferred vault mapping without moving funds.
// SendEarnRewards v2: ERC4626 aggregator that routes deposits/withdraws
// into a single resolved SendEarn ERC4626 vault per action (no loops).
// Routing: factory.affiliates(user) if non-zero, else factory.SEND_EARN().
// Withdraw uses only the resolved vault and reverts if insufficient.
// Shares are transferable; streaming deferred.

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
Expand All @@ -16,62 +15,36 @@ import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";

import { ISuperfluidToken } from "@superfluid-finance/ethereum-contracts/contracts/interfaces/superfluid/ISuperfluidToken.sol";

interface IMinimalSendEarnFactory {
function isSendEarn(address target) external view returns (bool);
function affiliates(address who) external view returns (address);
function SEND_EARN() external view returns (address);
}

interface IMinimalHost {
function callAgreement(address agreementClass, bytes calldata callData, bytes calldata userData) external returns (bytes memory returnedData);
function getAgreementClass(bytes32 agreementType) external view returns (address);
}

interface IMinimalCFAv1 {
function createFlow(address token, address receiver, int96 flowRate, bytes calldata ctx) external returns (bytes memory newCtx);
function updateFlow(address token, address receiver, int96 flowRate, bytes calldata ctx) external returns (bytes memory newCtx);
function deleteFlow(address token, address sender, address receiver, bytes calldata ctx) external returns (bytes memory newCtx);
}

interface IMinimalCFAv1Read {
function getFlow(address token, address sender, address receiver) external view returns (uint256 timestamp, int96 flowRate, uint256 deposit, uint256 owedDeposit);
}
interface ISendEarnVault is IERC4626 {}

contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
using SafeERC20 for IERC20;
using SafeCast for uint256;

bytes32 public constant CONFIG_ROLE = keccak256("CONFIG_ROLE");

// Superfluid payout token (SENDx)
ISuperfluidToken public immutable sendx;
// Constructor accepts sendx for compatibility but streaming is deferred.
address public immutable sendx;

// Gate for vault acceptance
IMinimalSendEarnFactory public immutable factory;

// Per-user per-vault assets ledger (contract holds the actual vault shares)
mapping(address => mapping(address => uint256)) public assetsByVault; // user => vault => assets
mapping(address => uint256) public totalAssetsByUser; // aggregated per-user assets
// Per-user per-vault underlying shares held by this wrapper
mapping(address => mapping(address => uint256)) private _userUnderlyingShares;

// per-user flow rate cache
mapping(address => int96) public flowRateByUser;
// Tracked vaults the wrapper has interacted with (for view-only totalAssets)
address[] private _activeVaults;
mapping(address => bool) private _isActiveVault;

// streaming config
uint96 public annualRateBps = 300; // 3%
uint256 public secondsPerYear = 365 days;
uint256 public exchangeRateWad = 1e18; // asset->SENDx conversion

// default and per-user preferred SendEarn vault routing
address public defaultDepositVault;
mapping(address => address) public depositVaultOf;

event Deposited(address indexed user, address indexed vault, uint256 assetsIn, int96 newRate);
event Withdrawn(address indexed user, address indexed vault, uint256 assetsOut, int96 newRate);
event FlowSet(address indexed user, int96 oldRate, int96 newRate);
event ConfigUpdated(uint96 annualRateBps, uint256 secondsPerYear, uint256 exchangeRateWad);
event VaultPreferenceSet(address indexed user, address indexed vault);
event Deposited(address indexed user, address indexed vault, uint256 assetsIn, uint256 underlyingSharesReceived);
event Withdrawn(address indexed user, address indexed vault, uint256 assetsOut, uint256 underlyingSharesRedeemed);

constructor(
address _sendx,
Expand All @@ -85,19 +58,13 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
require(_factory != address(0), "factory");
require(_asset != address(0), "asset");
require(admin != address(0), "admin");
sendx = ISuperfluidToken(_sendx);
sendx = _sendx; // kept for compatibility; not used in v2
factory = IMinimalSendEarnFactory(_factory);
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(CONFIG_ROLE, admin);
}

// Non-transferable shares (mint/burn only)
function _update(address from, address to, uint256 value) internal override {
if (from != address(0) && to != address(0)) revert("non-transferable");
super._update(from, to, value);
}

// ERC4626 public entry points (reentrancy guarded) that bubble into hooks
// ERC4626 entry points (reentrancy guarded)
function deposit(uint256 assets, address receiver) public override nonReentrant returns (uint256 shares) {
return super.deposit(assets, receiver);
}
Expand All @@ -111,106 +78,74 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
return super.redeem(shares, receiver, owner);
}

// 1:1 shares<->assets to keep accounting simple while we aggregate across SendEarn vaults
function totalAssets() public view override returns (uint256) { return totalSupply(); }
// Accounting: sum across tracked SendEarn vaults (view-only) for totalAssets.
function totalAssets() public view override returns (uint256 assets) {
uint256 n = _activeVaults.length;
for (uint256 i = 0; i < n; i++) {
address v = _activeVaults[i];
uint256 shares = IERC4626(v).balanceOf(address(this));
if (shares != 0) {
assets += IERC4626(v).convertToAssets(shares);
}
}
}
function _convertToShares(uint256 assets, Math.Rounding) internal view override returns (uint256) { return assets; }
function _convertToAssets(uint256 shares, Math.Rounding) internal view override returns (uint256) { return shares; }

// Hook pattern (like SendEarn): super then interact with target vault
// Helpers
function userUnderlyingShares(address user, address vault) external view returns (uint256) {
return _userUnderlyingShares[user][vault];
}

// Hooks: interact with the resolved SendEarn vault
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
// Move assets from caller -> this and mint wrapper shares via super
super._deposit(caller, receiver, assets, shares);
address v = _resolveVault(receiver);
IERC20(asset()).forceApprove(v, 0);

// Resolve receiver's vault and deposit underlying assets
address v = _resolveVaultFor(receiver);
if (!_isActiveVault[v]) { _isActiveVault[v] = true; _activeVaults.push(v); }
IERC20(asset()).forceApprove(v, 0);
IERC20(asset()).forceApprove(v, assets);
IERC4626(v).deposit(assets, address(this));
assetsByVault[receiver][v] += assets;
totalAssetsByUser[receiver] += assets;
int96 rate = _recomputeAndSetFlow(receiver);
emit Deposited(receiver, v, assets, rate);
uint256 underlyingSharesReceived = IERC4626(v).deposit(assets, address(this));

// Attribute the received underlying shares to the receiver
_userUnderlyingShares[receiver][v] += underlyingSharesReceived;

emit Deposited(receiver, v, assets, underlyingSharesReceived);
}

function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal override {
address v = _resolveVault(owner);
uint256 bal = assetsByVault[owner][v];
require(bal >= assets, "assets>bal");
IERC4626(v).withdraw(assets, address(this), address(this));
// Resolve owner's vault
address v = _resolveVaultFor(owner);
IERC4626 vault = IERC4626(v);

// Compute underlying shares needed (round up) to get `assets`
uint256 underlyingSharesToRedeem = vault.previewWithdraw(assets);
require(_userUnderlyingShares[owner][v] >= underlyingSharesToRedeem, "insufficient underlying shares");

// Redeem underlying shares into this contract
uint256 assetsRedeemed = vault.redeem(underlyingSharesToRedeem, address(this), address(this));
require(assetsRedeemed >= assets, "redeemed < assets");

// Burn wrapper shares and send out assets
super._withdraw(caller, receiver, owner, assets, shares);
assetsByVault[owner][v] = bal - assets;
totalAssetsByUser[owner] = totalAssetsByUser[owner] - assets;
int96 rate = _recomputeAndSetFlow(owner);
emit Withdrawn(owner, v, assets, rate);
}

// Preferred vault wrappers
// Allows joining with assets=0 to set mapping without moving funds
function depositAssets(address vault, uint256 assets) external nonReentrant {
address v = _validateVault(vault);
depositVaultOf[msg.sender] = v;
emit VaultPreferenceSet(msg.sender, v);
if (assets > 0) {
super.deposit(assets, msg.sender);
}
}
function withdrawAssets(address vault, uint256 assets, address receiver) external nonReentrant {
address v = _validateVault(vault);
depositVaultOf[msg.sender] = v;
emit VaultPreferenceSet(msg.sender, v);
if (assets > 0) {
address to = receiver == address(0) ? msg.sender : receiver;
super.withdraw(assets, to, msg.sender);
}
}
// Update accounting of user's underlying shares
_userUnderlyingShares[owner][v] -= underlyingSharesToRedeem;

// Views
function getUserVaultAssets(address who, address vault) external view returns (uint256) { return assetsByVault[who][vault]; }
function getFlowRate(address who) external view returns (int96) { return flowRateByUser[who]; }

// Admin config
function setAnnualRateBps(uint96 bps) external onlyRole(CONFIG_ROLE) { require(bps <= 10_000, "bps"); annualRateBps = bps; emit ConfigUpdated(annualRateBps, secondsPerYear, exchangeRateWad); }
function setSecondsPerYear(uint256 secs) external onlyRole(CONFIG_ROLE) { require(secs > 0, "secs"); secondsPerYear = secs; emit ConfigUpdated(annualRateBps, secondsPerYear, exchangeRateWad); }
function setExchangeRateWad(uint256 wad) external onlyRole(CONFIG_ROLE) { require(wad > 0, "rate"); exchangeRateWad = wad; emit ConfigUpdated(annualRateBps, secondsPerYear, exchangeRateWad); }
function setDefaultDepositVault(address vault) external onlyRole(CONFIG_ROLE) { defaultDepositVault = _validateVault(vault); }
function setDepositVault(address vault) external { depositVaultOf[msg.sender] = _validateVault(vault); emit VaultPreferenceSet(msg.sender, depositVaultOf[msg.sender]); }

// helpers
function _validateVault(address input) internal view returns (address v) {
require(input != address(0), "vault");
require(factory.isSendEarn(input), "not SendEarn");
v = input;
require(IERC4626(v).asset() == address(asset()), "asset mismatch");
emit Withdrawn(owner, v, assets, underlyingSharesToRedeem);
}
function _resolveVault(address user) internal view returns (address v) {
v = depositVaultOf[user];
if (v == address(0)) v = defaultDepositVault;

// Resolve the SendEarn vault for a given account.
function _resolveVaultFor(address who) internal view returns (address v) {
v = factory.affiliates(who);
if (v == address(0)) {
v = factory.SEND_EARN();
}
require(v != address(0), "no vault");
require(factory.isSendEarn(v), "not SendEarn");
require(IERC4626(v).asset() == address(asset()), "asset mismatch");
}

function _recomputeAndSetFlow(address user) internal returns (int96 newRate) {
uint256 valueWad = totalAssetsByUser[user] * exchangeRateWad;
uint256 annualWad = (valueWad * annualRateBps) / 10_000;
uint256 perSec = secondsPerYear == 0 ? 0 : annualWad / secondsPerYear / 1e18;
if (perSec > uint256(uint96(type(int96).max))) perSec = uint256(uint96(type(int96).max));
newRate = int96(int256(perSec));
int96 old = flowRateByUser[user];
if (newRate == old) return newRate;
address host = sendx.getHost();
address cfa = IMinimalHost(host).getAgreementClass(keccak256("org.superfluid-finance.agreements.ConstantFlowAgreement.v1"));
(, int96 current, ,) = IMinimalCFAv1Read(cfa).getFlow(address(sendx), address(this), user);
if (newRate == 0) {
if (current != 0) {
IMinimalHost(host).callAgreement(cfa, abi.encodeWithSelector(IMinimalCFAv1.deleteFlow.selector, address(sendx), address(this), user, new bytes(0)), new bytes(0));
}
} else {
if (current == 0) {
IMinimalHost(host).callAgreement(cfa, abi.encodeWithSelector(IMinimalCFAv1.createFlow.selector, address(sendx), user, newRate, new bytes(0)), new bytes(0));
} else {
IMinimalHost(host).callAgreement(cfa, abi.encodeWithSelector(IMinimalCFAv1.updateFlow.selector, address(sendx), user, newRate, new bytes(0)), new bytes(0));
}
}
flowRateByUser[user] = newRate;
emit FlowSet(user, old, newRate);
return newRate;
}
}
Loading