From 86ed748bfcd2113adfdaa14ca0543b18b2bac7ab Mon Sep 17 00:00:00 2001 From: youngkidwarrior Date: Wed, 24 Sep 2025 04:42:55 -0700 Subject: [PATCH 1/3] 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 --- contracts/rewards/SendEarnRewards.sol | 210 +++++++------------ docs/rewards-aggregator-erc4626.md | 25 +++ test/rewards/SendEarnRewards.erc4626.spec.ts | 188 ++++++++++++++--- 3 files changed, 253 insertions(+), 170 deletions(-) diff --git a/contracts/rewards/SendEarnRewards.sol b/contracts/rewards/SendEarnRewards.sol index 5abc6b5a..c8bc4417 100644 --- a/contracts/rewards/SendEarnRewards.sol +++ b/contracts/rewards/SendEarnRewards.sol @@ -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"; @@ -16,62 +15,35 @@ 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 flow rate cache - mapping(address => int96) public flowRateByUser; + // Per-user per-vault underlying shares held by this wrapper + mapping(address => mapping(address => uint256)) private _userUnderlyingShares; - // streaming config - uint96 public annualRateBps = 300; // 3% - uint256 public secondsPerYear = 365 days; - uint256 public exchangeRateWad = 1e18; // asset->SENDx conversion + // Tracked vaults the wrapper has interacted with (for view-only totalAssets) + address[] private _activeVaults; + mapping(address => bool) private _isActiveVault; - // 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, @@ -85,19 +57,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); } @@ -111,106 +77,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. + // Note: conversions remain 1:1 for simplicity while streaming is deferred. + 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; - } } diff --git a/docs/rewards-aggregator-erc4626.md b/docs/rewards-aggregator-erc4626.md index 50de6655..73ccdfd3 100644 --- a/docs/rewards-aggregator-erc4626.md +++ b/docs/rewards-aggregator-erc4626.md @@ -9,6 +9,11 @@ Status: Spec only (docs-first). Implementation and tests will follow in separate - Shares are transferable (standard ERC20 semantics). No non-transferable override. - No streaming dependencies in this spec. Streaming is deferred. +## Data model and asset/conversions +- Per-user per-vault underlying shares: the wrapper attributes underlying SendEarn vault shares to users in `_userUnderlyingShares[user][vault]`. +- Active vaults (wrapper-wide): first time a deposit is made into a vault, it’s added to `_activeVaults` for view-only `totalAssets()`. +- 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). + ## Asset and conversions - The aggregator’s `asset()` equals the underlying asset used by all routed SendEarn vaults (e.g., USDC). - 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: ``` ## Deposit routing (selection policy) +Resolution order for each action (deposit/withdraw): +- Use `affiliates(account)` if non-zero; else `SEND_EARN()`. +- Note: Changing your affiliate does not migrate legacy underlying shares. It only affects future actions’ resolution. + - 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. Resolution order for deposits by caller: 1) If `factory.affiliates(caller) != address(0)`, route deposit to that SendEarn vault. 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: ## Withdraw policy (gas‑efficient; no loops) - Withdraw uses a single vault only: resolve the vault via `affiliates(owner)`; if empty, use `SEND_EARN()`. +- Redeem from that resolved vault exclusively; do not call multiple vaults. +- 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 for finer control. + +## Transfers (re‑attribute underlying shares, no external calls) +- Wrapper shares are transferable. +- 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. +- This is an in‑memory loop over the sender’s active vault list; no vault external calls are made. +- 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). +- Withdraw uses a single vault only: resolve the vault via `affiliates(owner)`; if empty, use `SEND_EARN()`. - Redeem from that resolved vault exclusively; do not loop across multiple vaults. - 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. +## Total assets (view-only) +- `totalAssets()` sums across wrapper-held positions: + - For each tracked vault v in `_activeVaults`: + - `assets += IERC4626(v).convertToAssets(IERC4626(v).balanceOf(address(this)))` +- This is a view-only iteration. State-changing flows remain single-vault without loops. + ## Transferability - Aggregator shares follow normal ERC20 semantics: transfers are allowed. The aggregator does not maintain per‑user vault ledgers. diff --git a/test/rewards/SendEarnRewards.erc4626.spec.ts b/test/rewards/SendEarnRewards.erc4626.spec.ts index 348e71ab..4c130351 100644 --- a/test/rewards/SendEarnRewards.erc4626.spec.ts +++ b/test/rewards/SendEarnRewards.erc4626.spec.ts @@ -1,44 +1,168 @@ import { expect } from "chai"; import hre from "hardhat"; -// v2 ERC4626 Aggregator tests (streaming deferred) -// Intent: outline domain behavior; many cases are pending until the implementation is rewritten. -// Template sources used (no blind code): -// - Existing spec structure: super-send-token/test/rewards/SendEarnRewards.spec.ts -// - Factory interface semantics: send-earn-contracts/src/interfaces/ISendEarnFactory.sol -// - Vault mocks: super-send-token/contracts/mocks/MinimalVault.sol (ERC4626TestVault) - describe("SendEarnRewards v2 (ERC4626 only; streaming deferred)", () => { - it.skip("routes deposit to affiliates(caller) when set; otherwise prefers default SEND_EARN if caller already holds default shares", async function () { - // Pending: requires v2 implementation (no CFA calls in hooks) and a factory mock exposing affiliates() and SEND_EARN(). - // Outline: - // - Deploy ERC20Mintable (underlying) - // - Deploy 2 ERC4626TestVaults (affiliateVault, defaultVault) - // - Deploy SendEarnFactoryAffiliatesMock - // - Set isSendEarn for both vaults; set SEND_EARN = defaultVault; set affiliates[user] = affiliateVault - // - Deploy v2 aggregator (asset = underlying) - // - Approve+deposit X assets via aggregator → expect aggregator to hold shares in affiliateVault - // - Clear affiliates[user]; give user direct seUSDC in defaultVault; deposit again → expect route to defaultVault + async function deployFixture() { + const [deployer, userA, userB] = await hre.viem.getWalletClients(); + const pub = await hre.viem.getPublicClient(); + + // Underlying ERC20 + const erc20 = await hre.viem.deployContract( + "ERC20Mintable", + ["MockUSD", "mUSD"] as const, + { client: { wallet: deployer } } + ); + + // Two SendEarn-like ERC4626 test vaults + const vaultA = await hre.viem.deployContract( + "ERC4626TestVault", + [erc20.address, "VaultA", "vA"] as const, + { client: { wallet: deployer } } + ); + const vaultB = await hre.viem.deployContract( + "ERC4626TestVault", + [erc20.address, "VaultB", "vB"] as const, + { client: { wallet: deployer } } + ); + + // Factory mock with affiliates + SEND_EARN + const factory = await hre.viem.deployContract( + "SendEarnFactoryAffiliatesMock", + [] as const, + { client: { wallet: deployer } } + ); + // Mark both vaults as SendEarn and set default to B + await deployer.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setIsSendEarn", args: [vaultA.address, true] }); + await deployer.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setIsSendEarn", args: [vaultB.address, true] }); + await deployer.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setSendEarn", args: [vaultB.address] }); + + // Aggregator (sendx can be any non-zero address; unused) + const name = "SendEarnRewards v2"; + const symbol = "sREW2"; + const sendx = deployer.account!.address as `0x${string}`; + const rewards = await hre.viem.deployContract( + "SendEarnRewards", + [sendx, factory.address, erc20.address, name, symbol, deployer.account!.address] as const, + { client: { wallet: deployer } } + ); + + return { pub, deployer, userA, userB, erc20, vaultA, vaultB, factory, rewards }; + } + + it("routes deposit to affiliates(user) when set; records underlying shares", async () => { + const { pub, deployer, userA, erc20, vaultA, vaultB, factory, rewards } = await deployFixture(); + const a = userA.account!.address as `0x${string}`; + + // Mint to userA and approve aggregator + await deployer.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "mint", args: [a, 1_000n * 10n ** 18n] as any }); + await userA.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "approve", args: [rewards.address, 500n * 10n ** 18n] }); + + // Set affiliates(userA) = vaultA + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] }); + + const assets = 200n * 10n ** 18n; + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "deposit", args: [assets, a] }); + + // Wrapper shares 1:1 + const bal = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] }); + expect(bal).to.equal(assets); + + // Underlying shares recorded + const underlyingShares: bigint = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultA.address] }); + const expectedShares = await pub.readContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "convertToShares", args: [assets] }); + expect(underlyingShares).to.equal(expectedShares); + + // Default SEND_EARN is vaultB; ensure no shares recorded there for userA yet + const zeroShares: bigint = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultB.address] }); + expect(zeroShares).to.equal(0n); }); - it.skip("shares are transferable (no non-transferable override)", async function () { - // Pending: current implementation overrides _update to disallow transfers. - // Outline: - // - Mint aggregator shares to user (via deposit) - // - transfer to other user → should succeed + it("withdraw uses only the resolved vault and reverts if insufficient; succeeds when sufficient", async () => { + const { pub, deployer, userA, erc20, vaultA, factory, rewards } = await deployFixture(); + const a = userA.account!.address as `0x${string}`; + + // Mint and approve + await deployer.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "mint", args: [a, 1_000n * 10n ** 18n] as any }); + await userA.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "approve", args: [rewards.address, 500n * 10n ** 18n] }); + + // affiliates(userA)=vaultA; deposit 100 + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] }); + const depositAmt = 100n * 10n ** 18n; + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "deposit", args: [depositAmt, a] }); + + // Change affiliate to zero (default), withdraw should now resolve to default and revert due to no underlying shares there + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, "0x0000000000000000000000000000000000000000"] }); + let reverted = false; + try { + await userA.simulateContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "withdraw", args: [10n * 10n ** 18n, a, a] }); + } catch { reverted = true; } + expect(reverted).to.eq(true); + + // Set affiliate back to vaultA and withdraw 40 (single vault) + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] }); + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "withdraw", args: [40n * 10n ** 18n, a, a] }); + + const balAfter = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] }); + expect(balAfter).to.equal(depositAmt - 40n * 10n ** 18n); }); - it.skip("totalAssets equals the sum over held SendEarn vault positions converted via convertToAssets", async function () { - // Pending: current implementation uses 1:1 accounting; v2 should compute sum over held vaults. - // Outline: - // - After routing deposits to two vaults, read totalAssets and compare to per-vault convertToAssets balances + it("totalAssets equals sum over tracked vaults convertToAssets(wrapper-held shares)", async () => { + const { pub, deployer, userA, erc20, vaultA, vaultB, factory, rewards } = await deployFixture(); + const a = userA.account!.address as `0x${string}`; + + await deployer.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "mint", args: [a, 2_000n * 10n ** 18n] as any }); + await userA.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "approve", args: [rewards.address, 2_000n * 10n ** 18n] }); + + // Deposit 150 into affiliate vaultA + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] }); + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "deposit", args: [150n * 10n ** 18n, a] }); + + // Deposit 50 into default vaultB (clear affiliate) + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, "0x0000000000000000000000000000000000000000"] }); + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "deposit", args: [50n * 10n ** 18n, a] }); + + // Compute expected total assets from wrapper-held shares in both vaults + const sharesA = await pub.readContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "balanceOf", args: [rewards.address] }); + const sharesB = await pub.readContract({ address: vaultB.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "balanceOf", args: [rewards.address] }); + const assetsA = await pub.readContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "convertToAssets", args: [sharesA] }); + const assetsB = await pub.readContract({ address: vaultB.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "convertToAssets", args: [sharesB] }); + + const total = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "totalAssets", args: [] }); + expect(total).to.equal((assetsA as bigint) + (assetsB as bigint)); }); -it.skip("withdraw uses only the resolved vault and reverts if insufficient (no loops)", async function () { - // Gas-efficient policy: no multi-vault loops in withdraw. - // Outline: - // - With positions in two vaults (historical deposits), set affiliates(owner) to affiliateVault so it resolves there - // - Attempt withdraw X where affiliateVault can fully satisfy → succeeds and only that vault’s position decreases - // - Attempt withdraw Y where affiliateVault cannot satisfy → expect revert (no fallback loop into other vault) + it("transfers re-attribute underlying shares proportionally across sender's active vaults", async () => { + const { pub, deployer, userA, userB, erc20, vaultA, factory, rewards } = await deployFixture(); + const a = userA.account!.address as `0x${string}`; + const b = userB.account!.address as `0x${string}`; + + await deployer.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "mint", args: [a, 1_000n * 10n ** 18n] as any }); + await userA.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "approve", args: [rewards.address, 1_000n * 10n ** 18n] }); + + // affiliates(userA)=vaultA; deposit 300 + await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] }); + const depositAmt = 300n * 10n ** 18n; + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "deposit", args: [depositAmt, a] }); + + const senderSharesBefore = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] }); + const fromUnderlyingBefore = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultA.address] }); + + // Transfer 120 wrapper shares to userB + const xfer = 120n * 10n ** 18n; + await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "transfer", args: [b, xfer] }); + + const moved = (fromUnderlyingBefore as bigint) * xfer / (senderSharesBefore as bigint); + const fromUnderlyingAfter = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultA.address] }); + const toUnderlyingAfter = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [b, vaultA.address] }); + + expect((fromUnderlyingBefore as bigint) - (fromUnderlyingAfter as bigint)).to.equal(moved); + expect(toUnderlyingAfter).to.equal(moved); + + // Set affiliates(userB)=vaultA then withdraw a portion successfully + await userB.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [b, vaultA.address] }); + await userB.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "withdraw", args: [20n * 10n ** 18n, b, b] }); + + const toBal = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [b] }); + expect(toBal).to.equal(xfer - 20n * 10n ** 18n); }); }); From 18fd8c1735f0ec74883646201661145edf580bab Mon Sep 17 00:00:00 2001 From: youngkidwarrior Date: Wed, 24 Sep 2025 05:54:24 -0700 Subject: [PATCH 2/3] Revert per-user transfer re-attribution; remove _update override Why: - Transfers no longer re-attribute underlying shares; we will update flows and asset mappings only on deposit/withdraw/redeem per the corrected docs - Remove per-user active vault tracking used only for re-attribution Test plan: - Compile --- contracts/rewards/SendEarnRewards.sol | 3 +- docs/rewards-aggregator-erc4626.md | 50 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/contracts/rewards/SendEarnRewards.sol b/contracts/rewards/SendEarnRewards.sol index c8bc4417..69f6dff3 100644 --- a/contracts/rewards/SendEarnRewards.sol +++ b/contracts/rewards/SendEarnRewards.sol @@ -42,6 +42,7 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard { address[] private _activeVaults; mapping(address => bool) private _isActiveVault; + event Deposited(address indexed user, address indexed vault, uint256 assetsIn, uint256 underlyingSharesReceived); event Withdrawn(address indexed user, address indexed vault, uint256 assetsOut, uint256 underlyingSharesRedeemed); @@ -78,7 +79,6 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard { } // Accounting: sum across tracked SendEarn vaults (view-only) for totalAssets. - // Note: conversions remain 1:1 for simplicity while streaming is deferred. function totalAssets() public view override returns (uint256 assets) { uint256 n = _activeVaults.length; for (uint256 i = 0; i < n; i++) { @@ -147,4 +147,5 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard { require(factory.isSendEarn(v), "not SendEarn"); require(IERC4626(v).asset() == address(asset()), "asset mismatch"); } + } diff --git a/docs/rewards-aggregator-erc4626.md b/docs/rewards-aggregator-erc4626.md index 73ccdfd3..3d1a8485 100644 --- a/docs/rewards-aggregator-erc4626.md +++ b/docs/rewards-aggregator-erc4626.md @@ -59,6 +59,56 @@ All routed targets MUST satisfy: - `assets += IERC4626(v).convertToAssets(IERC4626(v).balanceOf(address(this)))` - This is a view-only iteration. State-changing flows remain single-vault without loops. +## CFA streaming integration (v2.1) — Superfluid “flow” helper +Status: planned in v2.1 (docs-first; implementation next). + +Goal +- Stream SENDx (SuperToken) per user at a rate proportional to their current value in the aggregator. +- Use Superfluid’s SuperTokenV1Library `flow(ISuperToken token, address receiver, int96 rate)` helper to create/update/delete flows. + +Key concepts +- Token: `sendx` (constructor arg), an ISuperToken. The aggregator must be pre-funded with SENDx to cover the flow buffer. +- Triggers: Recompute a user’s flow after each state-changing event that affects the user’s value: + - deposit(receiver) + - withdraw(owner) + - transfer(from→to): recompute for both parties after proportional re-attribution +- Value basis (per user): sum across the user’s active vaults of `convertToAssets(_userUnderlyingShares[user][vault])`. +- Policy params: + - `annualRateBps`: annualized rate in basis points (e.g., 300 = 3%) + - `secondsPerYear`: denominator to convert annual to per-second + - `exchangeRateWad`: asset→SENDx conversion (fixed-point); default 1e18 for 1:1 +- Flow math (per second): + - `perSecond = floor( (sumAssets * exchangeRateWad) * annualRateBps / 10_000 / secondsPerYear / 1e18 )` + - Use `int96` cast; if perSecond==0, call `flow(token, user, 0)` to delete. + +Integration outline (pseudocode) +- Import library: `using SuperTokenV1Library for ISuperToken;` +- After event, recompute and set flow: +``` +function _updateFlow(address user) internal { + uint256 assets = 0; + for (address v in _userActiveVaults[user]) { + uint256 uShares = _userUnderlyingShares[user][v]; + if (uShares == 0) continue; + assets += IERC4626(v).convertToAssets(uShares); + } + uint256 wad = assets * exchangeRateWad; + uint256 annual = wad * annualRateBps / 10_000; + uint256 perSec = annual / secondsPerYear / 1e18; + int96 rate = int96(int256(perSec)); + sendx.flow(user, rate); // create/update/delete as needed +} +``` + +Operational notes +- Pre-fund with SENDx to satisfy CFA buffer or the flow creation will revert. +- Flows are per-user, from the aggregator to the user. +- If you change `annualRateBps`/`exchangeRateWad`, you may optionally batch-recompute flows for a set of users (future helper). + +References +- SuperTokenV1Library: https://github.com/superfluid-finance/protocol-monorepo/blob/dev/packages/ethereum-contracts/contracts/apps/SuperTokenV1Library.sol +- CFA docs: https://docs.superfluid.finance/superfluid/developers/constant-flow-agreement-cfa + ## Transferability - Aggregator shares follow normal ERC20 semantics: transfers are allowed. The aggregator does not maintain per‑user vault ledgers. From e9ede2e01f2f326ab8f40b534b8ffa19f627b04b Mon Sep 17 00:00:00 2001 From: youngkidwarrior Date: Wed, 24 Sep 2025 06:24:34 -0700 Subject: [PATCH 3/3] Docs: expand CFA v2.1 into professional spec - Purpose/scope, definitions, dependencies, state model, invariants, resolution - Triggers, placeholder policy, lifecycle flows with precise steps - Rounding/safety/observability/failure modes/implementation notes/open items Test plan: - Render docs; verify sections and step details --- docs/rewards-aggregator-erc4626.md | 175 +++++++++++++++++++++-------- 1 file changed, 130 insertions(+), 45 deletions(-) diff --git a/docs/rewards-aggregator-erc4626.md b/docs/rewards-aggregator-erc4626.md index 3d1a8485..535b16c2 100644 --- a/docs/rewards-aggregator-erc4626.md +++ b/docs/rewards-aggregator-erc4626.md @@ -59,51 +59,136 @@ All routed targets MUST satisfy: - `assets += IERC4626(v).convertToAssets(IERC4626(v).balanceOf(address(this)))` - This is a view-only iteration. State-changing flows remain single-vault without loops. -## CFA streaming integration (v2.1) — Superfluid “flow” helper -Status: planned in v2.1 (docs-first; implementation next). - -Goal -- Stream SENDx (SuperToken) per user at a rate proportional to their current value in the aggregator. -- Use Superfluid’s SuperTokenV1Library `flow(ISuperToken token, address receiver, int96 rate)` helper to create/update/delete flows. - -Key concepts -- Token: `sendx` (constructor arg), an ISuperToken. The aggregator must be pre-funded with SENDx to cover the flow buffer. -- Triggers: Recompute a user’s flow after each state-changing event that affects the user’s value: - - deposit(receiver) - - withdraw(owner) - - transfer(from→to): recompute for both parties after proportional re-attribution -- Value basis (per user): sum across the user’s active vaults of `convertToAssets(_userUnderlyingShares[user][vault])`. -- Policy params: - - `annualRateBps`: annualized rate in basis points (e.g., 300 = 3%) - - `secondsPerYear`: denominator to convert annual to per-second - - `exchangeRateWad`: asset→SENDx conversion (fixed-point); default 1e18 for 1:1 -- Flow math (per second): - - `perSecond = floor( (sumAssets * exchangeRateWad) * annualRateBps / 10_000 / secondsPerYear / 1e18 )` - - Use `int96` cast; if perSecond==0, call `flow(token, user, 0)` to delete. - -Integration outline (pseudocode) -- Import library: `using SuperTokenV1Library for ISuperToken;` -- After event, recompute and set flow: -``` -function _updateFlow(address user) internal { - uint256 assets = 0; - for (address v in _userActiveVaults[user]) { - uint256 uShares = _userUnderlyingShares[user][v]; - if (uShares == 0) continue; - assets += IERC4626(v).convertToAssets(uShares); - } - uint256 wad = assets * exchangeRateWad; - uint256 annual = wad * annualRateBps / 10_000; - uint256 perSec = annual / secondsPerYear / 1e18; - int96 rate = int96(int256(perSec)); - sendx.flow(user, rate); // create/update/delete as needed -} -``` - -Operational notes -- Pre-fund with SENDx to satisfy CFA buffer or the flow creation will revert. -- Flows are per-user, from the aggregator to the user. -- If you change `annualRateBps`/`exchangeRateWad`, you may optionally batch-recompute flows for a set of users (future helper). +## CFA streaming integration (v2.1) — Professional spec + +Status: planned (docs-first); implementation follows. The flowRate calculation component is not finalized; all calls to `flow` are placeholders pending that integration. + +### Purpose and scope +- Provide continuous SENDx streaming to users sized by the value of their aggregated SendEarn positions held via the aggregator. +- Integrate Superfluid using SuperTokenV1Library `flow` helper to create/update/delete flows on state changes. +- Scope covers deposit (vault token ingestion), withdraw, and redeem. ERC20 share transfers do not alter ledger or flows. + +### Definitions +- Vault: a SendEarn ERC4626 vault approved by the factory. +- Vault shares: ERC4626 shares of a SendEarn vault (seASSET tokens) held by the aggregator. +- Aggregated assets (per user): sum over user’s vaults of `IERC4626(v).convertToAssets(userUnderlyingShares[v])`. +- sendx: the SuperToken used for streaming (constructor arg). Must be pre-funded in the aggregator. + +### External dependencies +- Superfluid protocol; use `SuperTokenV1Library` for `flow(ISuperToken token, address receiver, int96 rate)`. +- SendEarnFactory (gating): `isSendEarn(vault)`; `SEND_EARN()` default vault. +- ERC4626 interface: `convertToAssets(shares)`, `convertToShares(assets)`. + +### State model (relevant to streaming) +- Per-user, per-vault underlying shares: + - `_userUnderlyingShares[user][vault] -> uint256` +- Wrapper-wide active vaults (for view-only aggregation): + - `_activeVaults[]` and `_isActiveVault[vault]` +- Note: we do not modify per-user ledgers on ERC20 transfers. Flows/ledgers are updated only on deposit/withdraw/redeem. + +### Invariants +- Vault gating: `factory.isSendEarn(vault) == true` before accepting any vault shares. +- Asset invariant: `IERC4626(vault).asset() == asset()` of the aggregator. +- Single-vault mutations: withdraw/redeem operate on a single, resolved vault per action; no multi-vault loops. +- No ledger changes on ERC20 share transfers. + +### Resolution policy +- For any action on behalf of `account`: resolve `vault = factory.affiliates(account)` if non-zero; else `factory.SEND_EARN()`. +- Affiliate changes affect future actions only; existing per-user vault share ledgers are not migrated. + +### Flows and triggers +- Trigger `_recomputeAndFlow(user)` after: + - Deposit (vault token ingestion) + - Withdraw (assets) + - Redeem (shares) +- Transfers DO NOT trigger flow updates. + +### Flow rate policy (placeholder) +- `flowRate = f(aggregatedAssets(user), policy)` where: + - `aggregatedAssets(user) = Σ_v convertToAssets(_userUnderlyingShares[user][v])` + - Policy inputs (configurable): `annualRateBps`, `secondsPerYear`, `exchangeRateWad` +- Compute per-second rate (placeholder): + - `valueWad = aggregatedAssets * exchangeRateWad` + - `annualWad = valueWad * annualRateBps / 10_000` + - `perSecond = floor(annualWad / secondsPerYear / 1e18)` + - `rate = int96(perSecond)`; if `rate == 0`, delete flow +- NOTE: Final policy/oracle component to be integrated; until then, treat `flow()` invocations as stubs. + +### Lifecycle flows + +#### Deposit (vault token ingestion) +1) User transfers SendEarn vault shares to SendEarnRewards (e.g., `depositVaultShares(vault, shares)`). +2) Validate: `factory.isSendEarn(vault)` and `IERC4626(vault).asset() == asset()`. +3) Compute assets: `assets = IERC4626(vault).convertToAssets(shares)` (beware rounding; prefer protocol’s conversion semantics). +4) Ledger updates: + - `_userUnderlyingShares[user][vault] += shares` (store underlying shares to preserve precise value accrual semantics) + - Optionally maintain `totalAssetsByUser[user]` in view-only helpers by summing conversions on demand. +5) Track wrapper-wide vault activity: add `vault` to `_activeVaults` if first use. +6) Stream update (placeholder): call `sendx.flow(user, flowRate)`. +7) Events: `Deposited(user, vault, assets, shares)`. + +#### Withdraw (assets) +1) Resolve vault; compute required shares: `shares = IERC4626(vault).previewWithdraw(assets)`. +2) Verify user ledger has at least `shares` recorded; redeem from `vault` to this contract; send assets to `receiver=user`. +3) Ledger updates: `_userUnderlyingShares[user][vault] -= shares`. +4) Stream update (placeholder): call `sendx.flow(user, flowRate)`. +5) Events: `Withdrawn(user, vault, assets, shares)`. + +#### Redeem (shares) +1) Resolve vault and redeem directly in shares path. +2) Convert shares→assets via `IERC4626(vault).redeem(shares, this, this)` then send assets to `receiver=user`. +3) Ledger updates and stream update as in Withdraw. + +### Rounding and decimals +- Use ERC4626 preview functions for forward-looking conversions (`previewWithdraw`, `previewRedeem`). +- When converting vault shares to assets for aggregation, use `convertToAssets(shares)`; rounding follows the vault’s ERC4626 implementation. + +### Reentrancy and safety +- Wrap public entry points with `nonReentrant`. +- Use `forceApprove` (reset-to-zero then set) for ERC20 approvals. +- Never loop across multiple vaults in state-changing flows. + +### Observability +- Events: `Deposited(user, vault, assets, underlyingShares)`, `Withdrawn(user, vault, assets, underlyingShares)`. +- Views: `userUnderlyingShares(user, vault)`, `totalAssets()` (wrapper-wide; view-only over `_activeVaults`). + +### Failure modes +- Not SendEarn vault: revert. +- Asset mismatch: revert. +- Insufficient underlying shares on Withdraw/Redeem: revert. +- Flow creation/update may revert if SENDx buffer is insufficient (operator choice: pre-fund or skip flow set). + +### Implementation notes +- Keep vault interaction minimal and single-target per action. +- Flow update hooks are invoked after ledger mutation for the acting user. +- Do not attempt to adjust flows on ERC20 transfers. + +### Open items +- Plug in final flowRate component (oracle/policy); unit test flow lifecycle after integration. +- Optional: on admin policy change, batch-recompute flows across a given user subset. +Status: planned in v2.1 (docs-first; implementation next). Note: we do not yet have the final flowRate calculation component; calls to `flow` should be treated as placeholders until that piece is finalized. + +Library +- Use Superfluid’s SuperTokenV1Library `flow(ISuperToken token, address receiver, int96 rate)` to create/update/delete flows. + +Deposit (vault token ingestion) +1) User deposits a SendEarn vault token (shares) into SendEarnRewards. +2) SendEarnRewards checks `factory.isSendEarn(vault)`. +3) SendEarnRewards computes assets for the vault: `assets = IERC4626(vault).convertToAssets(shares)`. +4) SendEarnRewards updates its internal mappings linking vaults ⇄ users ⇄ assets (e.g., `assetsByVault[user][vault] += assets`, `totalAssetsByUser[user] += assets`). +5) SendEarnRewards calls `sendx.flow(user, flowRate)` via the library to reflect the new aggregated assets (flowRate: TODO — pending final component). + +Withdraw +1) SendEarnRewards withdraws assets held by the SendEarn underlying vault for the caller (redeeming vault shares it holds on behalf of the user). Receiver is the user linked to the vault. +2) SendEarnRewards updates mappings (decrement the user’s assets for that vault and total assets). +3) SendEarnRewards calls `sendx.flow(user, flowRate)` to reflect the reduced aggregated assets (flowRate: TODO — pending final component). + +Redeem +- Same as withdraw but the entry uses shares (wrapper redeem path should convert shares → assets and follow the same mapping + flow update sequence). + +Notes +- Exact `flowRate` computation is intentionally left as a TODO. We will integrate the final component (oracle/policy) to determine `flowRate` from the user’s aggregated assets. +- This flow does not rely on re-attributing underlying shares during ERC20 transfers. Flows and mappings update on deposit/withdraw/redeem only. References - SuperTokenV1Library: https://github.com/superfluid-finance/protocol-monorepo/blob/dev/packages/ethereum-contracts/contracts/apps/SuperTokenV1Library.sol