Skip to content

Commit 86ed748

Browse files
Tests: v2 routing, single-vault withdraw, totalAssets view, transfer re-attribution
- Deposit routes via affiliates(user) and records underlying shares - Withdraw resolves to a single vault and reverts when insufficient - totalAssets sums convertToAssets across wrapper-held positions in tracked vaults - Transfers proportionally re-attribute underlying shares (no external calls) Test plan: - Run: bunx hardhat test test/rewards/SendEarnRewards.erc4626.spec.ts
1 parent 07aa9ab commit 86ed748

File tree

3 files changed

+253
-170
lines changed

3 files changed

+253
-170
lines changed
Lines changed: 72 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
// SPDX-License-Identifier: MIT
22
pragma solidity ^0.8.28;
33

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

1110
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
1211
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
@@ -16,62 +15,35 @@ import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
1615
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
1716
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
1817
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
19-
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
20-
21-
import { ISuperfluidToken } from "@superfluid-finance/ethereum-contracts/contracts/interfaces/superfluid/ISuperfluidToken.sol";
2218

2319
interface IMinimalSendEarnFactory {
2420
function isSendEarn(address target) external view returns (bool);
21+
function affiliates(address who) external view returns (address);
22+
function SEND_EARN() external view returns (address);
2523
}
2624

27-
interface IMinimalHost {
28-
function callAgreement(address agreementClass, bytes calldata callData, bytes calldata userData) external returns (bytes memory returnedData);
29-
function getAgreementClass(bytes32 agreementType) external view returns (address);
30-
}
31-
32-
interface IMinimalCFAv1 {
33-
function createFlow(address token, address receiver, int96 flowRate, bytes calldata ctx) external returns (bytes memory newCtx);
34-
function updateFlow(address token, address receiver, int96 flowRate, bytes calldata ctx) external returns (bytes memory newCtx);
35-
function deleteFlow(address token, address sender, address receiver, bytes calldata ctx) external returns (bytes memory newCtx);
36-
}
37-
38-
interface IMinimalCFAv1Read {
39-
function getFlow(address token, address sender, address receiver) external view returns (uint256 timestamp, int96 flowRate, uint256 deposit, uint256 owedDeposit);
40-
}
25+
interface ISendEarnVault is IERC4626 {}
4126

4227
contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
4328
using SafeERC20 for IERC20;
44-
using SafeCast for uint256;
4529

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

48-
// Superfluid payout token (SENDx)
49-
ISuperfluidToken public immutable sendx;
32+
// Constructor accepts sendx for compatibility but streaming is deferred.
33+
address public immutable sendx;
5034

5135
// Gate for vault acceptance
5236
IMinimalSendEarnFactory public immutable factory;
5337

54-
// Per-user per-vault assets ledger (contract holds the actual vault shares)
55-
mapping(address => mapping(address => uint256)) public assetsByVault; // user => vault => assets
56-
mapping(address => uint256) public totalAssetsByUser; // aggregated per-user assets
57-
58-
// per-user flow rate cache
59-
mapping(address => int96) public flowRateByUser;
38+
// Per-user per-vault underlying shares held by this wrapper
39+
mapping(address => mapping(address => uint256)) private _userUnderlyingShares;
6040

61-
// streaming config
62-
uint96 public annualRateBps = 300; // 3%
63-
uint256 public secondsPerYear = 365 days;
64-
uint256 public exchangeRateWad = 1e18; // asset->SENDx conversion
41+
// Tracked vaults the wrapper has interacted with (for view-only totalAssets)
42+
address[] private _activeVaults;
43+
mapping(address => bool) private _isActiveVault;
6544

66-
// default and per-user preferred SendEarn vault routing
67-
address public defaultDepositVault;
68-
mapping(address => address) public depositVaultOf;
69-
70-
event Deposited(address indexed user, address indexed vault, uint256 assetsIn, int96 newRate);
71-
event Withdrawn(address indexed user, address indexed vault, uint256 assetsOut, int96 newRate);
72-
event FlowSet(address indexed user, int96 oldRate, int96 newRate);
73-
event ConfigUpdated(uint96 annualRateBps, uint256 secondsPerYear, uint256 exchangeRateWad);
74-
event VaultPreferenceSet(address indexed user, address indexed vault);
45+
event Deposited(address indexed user, address indexed vault, uint256 assetsIn, uint256 underlyingSharesReceived);
46+
event Withdrawn(address indexed user, address indexed vault, uint256 assetsOut, uint256 underlyingSharesRedeemed);
7547

7648
constructor(
7749
address _sendx,
@@ -85,19 +57,13 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
8557
require(_factory != address(0), "factory");
8658
require(_asset != address(0), "asset");
8759
require(admin != address(0), "admin");
88-
sendx = ISuperfluidToken(_sendx);
60+
sendx = _sendx; // kept for compatibility; not used in v2
8961
factory = IMinimalSendEarnFactory(_factory);
9062
_grantRole(DEFAULT_ADMIN_ROLE, admin);
9163
_grantRole(CONFIG_ROLE, admin);
9264
}
9365

94-
// Non-transferable shares (mint/burn only)
95-
function _update(address from, address to, uint256 value) internal override {
96-
if (from != address(0) && to != address(0)) revert("non-transferable");
97-
super._update(from, to, value);
98-
}
99-
100-
// ERC4626 public entry points (reentrancy guarded) that bubble into hooks
66+
// ERC4626 entry points (reentrancy guarded)
10167
function deposit(uint256 assets, address receiver) public override nonReentrant returns (uint256 shares) {
10268
return super.deposit(assets, receiver);
10369
}
@@ -111,106 +77,74 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
11177
return super.redeem(shares, receiver, owner);
11278
}
11379

114-
// 1:1 shares<->assets to keep accounting simple while we aggregate across SendEarn vaults
115-
function totalAssets() public view override returns (uint256) { return totalSupply(); }
80+
// Accounting: sum across tracked SendEarn vaults (view-only) for totalAssets.
81+
// Note: conversions remain 1:1 for simplicity while streaming is deferred.
82+
function totalAssets() public view override returns (uint256 assets) {
83+
uint256 n = _activeVaults.length;
84+
for (uint256 i = 0; i < n; i++) {
85+
address v = _activeVaults[i];
86+
uint256 shares = IERC4626(v).balanceOf(address(this));
87+
if (shares != 0) {
88+
assets += IERC4626(v).convertToAssets(shares);
89+
}
90+
}
91+
}
11692
function _convertToShares(uint256 assets, Math.Rounding) internal view override returns (uint256) { return assets; }
11793
function _convertToAssets(uint256 shares, Math.Rounding) internal view override returns (uint256) { return shares; }
11894

119-
// Hook pattern (like SendEarn): super then interact with target vault
95+
// Helpers
96+
function userUnderlyingShares(address user, address vault) external view returns (uint256) {
97+
return _userUnderlyingShares[user][vault];
98+
}
99+
100+
// Hooks: interact with the resolved SendEarn vault
120101
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
102+
// Move assets from caller -> this and mint wrapper shares via super
121103
super._deposit(caller, receiver, assets, shares);
122-
address v = _resolveVault(receiver);
123-
IERC20(asset()).forceApprove(v, 0);
104+
105+
// Resolve receiver's vault and deposit underlying assets
106+
address v = _resolveVaultFor(receiver);
107+
if (!_isActiveVault[v]) { _isActiveVault[v] = true; _activeVaults.push(v); }
108+
IERC20(asset()).forceApprove(v, 0);
124109
IERC20(asset()).forceApprove(v, assets);
125-
IERC4626(v).deposit(assets, address(this));
126-
assetsByVault[receiver][v] += assets;
127-
totalAssetsByUser[receiver] += assets;
128-
int96 rate = _recomputeAndSetFlow(receiver);
129-
emit Deposited(receiver, v, assets, rate);
110+
uint256 underlyingSharesReceived = IERC4626(v).deposit(assets, address(this));
111+
112+
// Attribute the received underlying shares to the receiver
113+
_userUnderlyingShares[receiver][v] += underlyingSharesReceived;
114+
115+
emit Deposited(receiver, v, assets, underlyingSharesReceived);
130116
}
131117

132118
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal override {
133-
address v = _resolveVault(owner);
134-
uint256 bal = assetsByVault[owner][v];
135-
require(bal >= assets, "assets>bal");
136-
IERC4626(v).withdraw(assets, address(this), address(this));
119+
// Resolve owner's vault
120+
address v = _resolveVaultFor(owner);
121+
IERC4626 vault = IERC4626(v);
122+
123+
// Compute underlying shares needed (round up) to get `assets`
124+
uint256 underlyingSharesToRedeem = vault.previewWithdraw(assets);
125+
require(_userUnderlyingShares[owner][v] >= underlyingSharesToRedeem, "insufficient underlying shares");
126+
127+
// Redeem underlying shares into this contract
128+
uint256 assetsRedeemed = vault.redeem(underlyingSharesToRedeem, address(this), address(this));
129+
require(assetsRedeemed >= assets, "redeemed < assets");
130+
131+
// Burn wrapper shares and send out assets
137132
super._withdraw(caller, receiver, owner, assets, shares);
138-
assetsByVault[owner][v] = bal - assets;
139-
totalAssetsByUser[owner] = totalAssetsByUser[owner] - assets;
140-
int96 rate = _recomputeAndSetFlow(owner);
141-
emit Withdrawn(owner, v, assets, rate);
142-
}
143133

144-
// Preferred vault wrappers
145-
// Allows joining with assets=0 to set mapping without moving funds
146-
function depositAssets(address vault, uint256 assets) external nonReentrant {
147-
address v = _validateVault(vault);
148-
depositVaultOf[msg.sender] = v;
149-
emit VaultPreferenceSet(msg.sender, v);
150-
if (assets > 0) {
151-
super.deposit(assets, msg.sender);
152-
}
153-
}
154-
function withdrawAssets(address vault, uint256 assets, address receiver) external nonReentrant {
155-
address v = _validateVault(vault);
156-
depositVaultOf[msg.sender] = v;
157-
emit VaultPreferenceSet(msg.sender, v);
158-
if (assets > 0) {
159-
address to = receiver == address(0) ? msg.sender : receiver;
160-
super.withdraw(assets, to, msg.sender);
161-
}
162-
}
134+
// Update accounting of user's underlying shares
135+
_userUnderlyingShares[owner][v] -= underlyingSharesToRedeem;
163136

164-
// Views
165-
function getUserVaultAssets(address who, address vault) external view returns (uint256) { return assetsByVault[who][vault]; }
166-
function getFlowRate(address who) external view returns (int96) { return flowRateByUser[who]; }
167-
168-
// Admin config
169-
function setAnnualRateBps(uint96 bps) external onlyRole(CONFIG_ROLE) { require(bps <= 10_000, "bps"); annualRateBps = bps; emit ConfigUpdated(annualRateBps, secondsPerYear, exchangeRateWad); }
170-
function setSecondsPerYear(uint256 secs) external onlyRole(CONFIG_ROLE) { require(secs > 0, "secs"); secondsPerYear = secs; emit ConfigUpdated(annualRateBps, secondsPerYear, exchangeRateWad); }
171-
function setExchangeRateWad(uint256 wad) external onlyRole(CONFIG_ROLE) { require(wad > 0, "rate"); exchangeRateWad = wad; emit ConfigUpdated(annualRateBps, secondsPerYear, exchangeRateWad); }
172-
function setDefaultDepositVault(address vault) external onlyRole(CONFIG_ROLE) { defaultDepositVault = _validateVault(vault); }
173-
function setDepositVault(address vault) external { depositVaultOf[msg.sender] = _validateVault(vault); emit VaultPreferenceSet(msg.sender, depositVaultOf[msg.sender]); }
174-
175-
// helpers
176-
function _validateVault(address input) internal view returns (address v) {
177-
require(input != address(0), "vault");
178-
require(factory.isSendEarn(input), "not SendEarn");
179-
v = input;
180-
require(IERC4626(v).asset() == address(asset()), "asset mismatch");
137+
emit Withdrawn(owner, v, assets, underlyingSharesToRedeem);
181138
}
182-
function _resolveVault(address user) internal view returns (address v) {
183-
v = depositVaultOf[user];
184-
if (v == address(0)) v = defaultDepositVault;
139+
140+
// Resolve the SendEarn vault for a given account.
141+
function _resolveVaultFor(address who) internal view returns (address v) {
142+
v = factory.affiliates(who);
143+
if (v == address(0)) {
144+
v = factory.SEND_EARN();
145+
}
185146
require(v != address(0), "no vault");
186147
require(factory.isSendEarn(v), "not SendEarn");
187148
require(IERC4626(v).asset() == address(asset()), "asset mismatch");
188149
}
189-
190-
function _recomputeAndSetFlow(address user) internal returns (int96 newRate) {
191-
uint256 valueWad = totalAssetsByUser[user] * exchangeRateWad;
192-
uint256 annualWad = (valueWad * annualRateBps) / 10_000;
193-
uint256 perSec = secondsPerYear == 0 ? 0 : annualWad / secondsPerYear / 1e18;
194-
if (perSec > uint256(uint96(type(int96).max))) perSec = uint256(uint96(type(int96).max));
195-
newRate = int96(int256(perSec));
196-
int96 old = flowRateByUser[user];
197-
if (newRate == old) return newRate;
198-
address host = sendx.getHost();
199-
address cfa = IMinimalHost(host).getAgreementClass(keccak256("org.superfluid-finance.agreements.ConstantFlowAgreement.v1"));
200-
(, int96 current, ,) = IMinimalCFAv1Read(cfa).getFlow(address(sendx), address(this), user);
201-
if (newRate == 0) {
202-
if (current != 0) {
203-
IMinimalHost(host).callAgreement(cfa, abi.encodeWithSelector(IMinimalCFAv1.deleteFlow.selector, address(sendx), address(this), user, new bytes(0)), new bytes(0));
204-
}
205-
} else {
206-
if (current == 0) {
207-
IMinimalHost(host).callAgreement(cfa, abi.encodeWithSelector(IMinimalCFAv1.createFlow.selector, address(sendx), user, newRate, new bytes(0)), new bytes(0));
208-
} else {
209-
IMinimalHost(host).callAgreement(cfa, abi.encodeWithSelector(IMinimalCFAv1.updateFlow.selector, address(sendx), user, newRate, new bytes(0)), new bytes(0));
210-
}
211-
}
212-
flowRateByUser[user] = newRate;
213-
emit FlowSet(user, old, newRate);
214-
return newRate;
215-
}
216150
}

docs/rewards-aggregator-erc4626.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ Status: Spec only (docs-first). Implementation and tests will follow in separate
99
- Shares are transferable (standard ERC20 semantics). No non-transferable override.
1010
- No streaming dependencies in this spec. Streaming is deferred.
1111

12+
## Data model and asset/conversions
13+
- Per-user per-vault underlying shares: the wrapper attributes underlying SendEarn vault shares to users in `_userUnderlyingShares[user][vault]`.
14+
- Active vaults (wrapper-wide): first time a deposit is made into a vault, it’s added to `_activeVaults` for view-only `totalAssets()`.
15+
- Active vaults (per-user): first time a user receives underlying shares for a vault, it’s added to that user’s active set for proportional re‑attribution on ERC20 transfers (no external calls).
16+
1217
## Asset and conversions
1318
- The aggregator’s `asset()` equals the underlying asset used by all routed SendEarn vaults (e.g., USDC).
1419
- Follow standard ERC4626 math for conversions; do not assume 1:1 shares/assets. `totalAssets()` reflects the current value of all held SendEarn vault shares converted via each vault’s `convertToAssets`.
@@ -20,6 +25,10 @@ sum over all held SendEarn vaults v:
2025
```
2126

2227
## Deposit routing (selection policy)
28+
Resolution order for each action (deposit/withdraw):
29+
- Use `affiliates(account)` if non-zero; else `SEND_EARN()`.
30+
- Note: Changing your affiliate does not migrate legacy underlying shares. It only affects future actions’ resolution.
31+
- Example: if you deposited into default vault and later set an affiliate to a different vault, withdraw resolves to the new affiliate vault and will revert unless you hold underlying shares there.
2332
Resolution order for deposits by caller:
2433
1) If `factory.affiliates(caller) != address(0)`, route deposit to that SendEarn vault.
2534
2) Else, let `d = factory.SEND_EARN()` (the default SendEarn vault). If `IERC20(d).balanceOf(caller) > 0` (caller already holds default shares), prefer `d`.
@@ -31,9 +40,25 @@ All routed targets MUST satisfy:
3140

3241
## Withdraw policy (gas‑efficient; no loops)
3342
- Withdraw uses a single vault only: resolve the vault via `affiliates(owner)`; if empty, use `SEND_EARN()`.
43+
- Redeem from that resolved vault exclusively; do not call multiple vaults.
44+
- If the resolved vault position is insufficient to satisfy the requested assets/shares, revert.
45+
- A non‑standard helper such as `withdrawFrom(vault, assets)` may be introduced later for finer control.
46+
47+
## Transfers (re‑attribute underlying shares, no external calls)
48+
- Wrapper shares are transferable.
49+
- On transfer, the wrapper proportionally re‑attributes the sender’s underlying shares across the sender’s active vaults to the receiver in proportion to the transferred wrapper shares over the sender’s pre‑transfer wrapper balance.
50+
- This is an in‑memory loop over the sender’s active vault list; no vault external calls are made.
51+
- Practical effect: the receiver can withdraw from any vaults the sender had underlying shares in (subject to the receiver’s affiliate resolution at withdraw time).
52+
- Withdraw uses a single vault only: resolve the vault via `affiliates(owner)`; if empty, use `SEND_EARN()`.
3453
- Redeem from that resolved vault exclusively; do not loop across multiple vaults.
3554
- If the resolved vault position is insufficient to satisfy the requested assets/shares, revert. A non‑standard helper such as `withdrawFrom(vault, assets)` may be introduced later if finer control is desired.
3655

56+
## Total assets (view-only)
57+
- `totalAssets()` sums across wrapper-held positions:
58+
- For each tracked vault v in `_activeVaults`:
59+
- `assets += IERC4626(v).convertToAssets(IERC4626(v).balanceOf(address(this)))`
60+
- This is a view-only iteration. State-changing flows remain single-vault without loops.
61+
3762
## Transferability
3863
- Aggregator shares follow normal ERC20 semantics: transfers are allowed. The aggregator does not maintain per‑user vault ledgers.
3964

0 commit comments

Comments
 (0)