Skip to content

Commit 4846449

Browse files
rewards: depositVaultShares pre-NAV; add tests
Why: Align depositVaultShares with ERC4626 preview semantics by minting shares using pre-ingestion NAV (snapshot totalAssets/totalSupply) so previewDeposit matches runtime behavior. Adds domain tests for SendEarn-wrapper behavior (routing, gating, NAV invariants, and depositVaultShares reverts). Gates CFA/integration suites until v2.1. Test plan: - npx hardhat test - Expect: ERC4626-only suite green; new NAV test passes; CFA/integration suites are pending unless RUN_CFA/RUN_INTEGRATION are set.
1 parent d89f73d commit 4846449

9 files changed

+724
-9
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# SendEarnRewards Aggregator – Temporary Design Reminder (delete later)
2+
3+
Status: Temporary notes to guide implementation decisions. Safe to delete after alignment.
4+
5+
## Short answer
6+
7+
Yes, it makes sense to front SendEarn with a separate ERC4626 “aggregator” vault that:
8+
- Holds SendEarn vault shares in its own custody, so accounting is authoritative and actions are visible
9+
- Exposes the standard ERC4626 interface (deposit/withdraw) for composability
10+
- Adds a non-standard path to onboard existing SendEarn shares (transfer them in)
11+
- Avoids upgrading SendEarn (no user migrations), preserves ERC4626 utility for integrators, and lets you aggregate value across vaults you control
12+
13+
## Desired behavior (restated)
14+
- Calling `deposit` on the aggregator should deposit underlying into a factory-approved SendEarn ERC4626 vault (and thus into its underlying), with the aggregator holding the resulting SendEarn shares.
15+
- Calling `withdraw` on the aggregator should withdraw from the SendEarn vault (and underlying), reducing the aggregator’s SendEarn share holdings accordingly.
16+
- For users already holding SendEarn shares, provide a function to transfer those shares into the aggregator. The aggregator mints new shares representing the user’s stake and updates flows based on underlying-equivalent value.
17+
18+
This mirrors SendEarn’s super-hook behavior while centralizing custody of the SendEarn shares in the aggregator.
19+
20+
## Why a separate ERC4626 wrapper-vault is a good fit
21+
- ERC4626 single-asset constraint: a single ERC4626 must accept one `asset()` token. We keep the aggregator’s asset as the underlying (e.g., USDC); deposits are routed to a chosen SendEarn vault (all sharing the same underlying). The aggregator holds the resulting SendEarn shares.
22+
- Composability: integrators use ERC4626 deposit/withdraw; aggregator handles routing/bubbling into SendEarn and custody of SendEarn shares.
23+
- Onboarding existing SendEarn share-holders: add a non-standard `depositVaultShares(vault, shares)` that pulls SendEarn shares from the user, mints aggregator shares equal to `convertToAssets(shares)`, and updates flows.
24+
- No migrations: no need to upgrade SendEarn or require user migrations. Users deposit into the aggregator, or transfer their existing shares into it.
25+
- Observability and correctness: since the aggregator holds the SendEarn shares, we can enforce withdraws through the aggregator and compute value via `vault.convertToAssets(heldShares)` at any time.
26+
27+
## Design details to get right
28+
- Per-user accounting: track per-user, per-vault shares; compute underlying-equivalent assets via `convertToAssets(shares)` for flows and displays.
29+
- ERC4626 “bubbling”: in `_deposit`, call `super._deposit` then deposit underlying into the selected SendEarn vault and hold shares; in `_withdraw`, withdraw underlying back from the SendEarn vault, then `super._withdraw`.
30+
- Existing share intake: `depositVaultShares(vault, shares)`:
31+
- `transferFrom` SendEarn shares into the aggregator
32+
- `assetsEq = vault.convertToAssets(shares)`
33+
- mint aggregator shares equal to `assetsEq`
34+
- update per-vault/user ledger and flows
35+
- Optional: share-return exit path `withdrawVaultShares(vault, shares)` (burn aggregator shares and transfer SendEarn shares back). Keep ERC4626 `withdraw` for the underlying exit expected by integrators.
36+
- Multi-vault aggregation & exits:
37+
- ERC4626 `withdraw(assets)` lacks a vault parameter; choose an exit policy (mapping-based, pro-rata, or add a non-standard `withdrawFrom(vault, assets)`). Start with mapping-based for simplicity.
38+
- Flow sizing and accrual: flows update at action boundaries (deposit/withdraw/intake). If you want continuously-accurate flows, add a maintenance function (e.g., `updateFlows(user)` or batch) that recomputes using current `convertToAssets(shares)`.
39+
40+
## Tradeoffs vs upgrading SendEarn
41+
- Upgrading SendEarn:
42+
- Pros: native flows; fewer moving parts
43+
- Cons: migration/user coordination, more risk, compatibility concerns
44+
- Aggregator ERC4626:
45+
- Pros: migration-free; preserves ERC4626 composability; easy to add share-intake; central policy control
46+
- Cons: one more contract; exit routing policy needed across multi-vault holdings
47+
48+
## Recommended shape (concrete)
49+
- Keep aggregator as ERC4626 with `asset()` = underlying
50+
- Retain super-hook routing so ERC4626 deposit/withdraw bubble into/out of the chosen SendEarn vault; hold SendEarn shares in aggregator
51+
- Add `depositVaultShares(vault, shares)` onboarding path for existing SendEarn share-holders. Consider optional `withdrawVaultShares` for share exits
52+
- Pick a clear withdraw policy (mapping-based to start) and document it
53+
- Flows computed from `vault.convertToAssets(userShares)`; update flows on each action
54+
55+
## Open questions / next choices
56+
- Do we want a share-return exit path (`withdrawVaultShares`) from the aggregator alongside ERC4626 withdraw?
57+
- Which exit policy do we standardize on for ERC4626 withdraw across multiple held vaults (mapping-based vs pro-rata)?
58+
- Should aggregator shares be non-transferable (simpler for flows) or transferable (more utility for integrations)? Current default: non-transferable
59+
60+
---
61+
Temporary reminder; safe to delete after alignment.

contracts/rewards/SendEarnRewards.sol

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,18 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
8989
}
9090
}
9191
}
92-
function _convertToShares(uint256 assets, Math.Rounding) internal view override returns (uint256) { return assets; }
93-
function _convertToAssets(uint256 shares, Math.Rounding) internal view override returns (uint256) { return shares; }
92+
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) {
93+
uint256 supply = totalSupply();
94+
uint256 total = totalAssets();
95+
if (supply == 0 || total == 0) return assets;
96+
return Math.mulDiv(assets, supply, total, rounding);
97+
}
98+
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) {
99+
uint256 supply = totalSupply();
100+
uint256 total = totalAssets();
101+
if (supply == 0) return 0;
102+
return Math.mulDiv(shares, total, supply, rounding);
103+
}
94104

95105
// Helpers
96106
function userUnderlyingShares(address user, address vault) external view returns (uint256) {
@@ -137,6 +147,37 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
137147
emit Withdrawn(owner, v, assets, underlyingSharesToRedeem);
138148
}
139149

150+
151+
// Accept existing SendEarn vault shares and mint aggregator shares by NAV
152+
function depositVaultShares(address vault, uint256 shares) external nonReentrant {
153+
require(shares > 0, "shares");
154+
require(factory.isSendEarn(vault), "not SendEarn");
155+
require(IERC4626(vault).asset() == address(asset()), "asset mismatch");
156+
if (!_isActiveVault[vault]) { _isActiveVault[vault] = true; _activeVaults.push(vault); }
157+
158+
// Compute assets-equivalent of incoming vault shares using the vault's ERC4626 conversion
159+
uint256 assetsEq = IERC4626(vault).convertToAssets(shares);
160+
161+
// Snapshot pre-ingestion NAV to align with previewDeposit semantics
162+
uint256 supplyBefore = totalSupply();
163+
uint256 assetsBefore = totalAssets();
164+
uint256 minted;
165+
if (supplyBefore == 0 || assetsBefore == 0) {
166+
minted = assetsEq;
167+
} else {
168+
minted = Math.mulDiv(assetsEq, supplyBefore, assetsBefore, Math.Rounding.Floor);
169+
}
170+
171+
// Pull vault shares from user into the aggregator and attribute to sender
172+
IERC20(vault).safeTransferFrom(msg.sender, address(this), shares);
173+
_userUnderlyingShares[msg.sender][vault] += shares;
174+
175+
// Mint aggregator shares computed from pre-ingestion NAV
176+
_mint(msg.sender, minted);
177+
178+
emit Deposited(msg.sender, vault, assetsEq, shares);
179+
}
180+
140181
// Resolve the SendEarn vault for a given account.
141182
function _resolveVaultFor(address who) internal view returns (address v) {
142183
v = factory.affiliates(who);

hardhat.config.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@ dotenv.config();
77
const config: HardhatUserConfig = {
88
solidity: "0.8.28",
99
networks: {
10-
// base mainnet network
10+
// Local in-process network for tests (no forking unless FORK_URL is set)
1111
hardhat: {
12-
chainId: 8453,
12+
chainId: 31337,
1313
hardfork: "cancun",
14-
forking: {
15-
url: "https://mainnet.base.org",
16-
},
14+
forking: process.env.FORK_URL ? { url: process.env.FORK_URL } : undefined,
1715
},
1816
anvil: {
1917
url: "http://127.0.0.1:8546",
@@ -23,12 +21,12 @@ const config: HardhatUserConfig = {
2321
sepolia: {
2422
url: "https://sepolia.base.org",
2523
chainId: 84532,
26-
accounts: [process.env.EOA_DEPLOYER!],
24+
accounts: process.env.EOA_DEPLOYER ? [process.env.EOA_DEPLOYER] : [],
2725
},
2826
base: {
2927
url: "https://mainnet.base.org",
3028
chainId: 8453,
31-
accounts: [process.env.EOA_DEPLOYER!],
29+
accounts: process.env.EOA_DEPLOYER ? [process.env.EOA_DEPLOYER] : [],
3230
},
3331
},
3432
etherscan: {

test/rewards.aggregator.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { expect } from "chai";
2+
import hre from "hardhat";
3+
import { getConfig } from "../config/superfluid";
4+
import { getContract } from "viem";
5+
import fs from "fs/promises";
6+
import path from "node:path";
7+
8+
async function readJson(file: string): Promise<any | null> {
9+
try { return JSON.parse(await fs.readFile(file, "utf8")); } catch { return null; }
10+
}
11+
12+
describe("RewardsAggregator (CFA flows)", function () {
13+
it("creates/updates CFA flow on deposit/withdraw (no env)", async function () {
14+
const publicClient = await hre.viem.getPublicClient();
15+
const [walletClient] = await hre.viem.getWalletClients();
16+
if (!walletClient) this.skip();
17+
18+
const chainId = await publicClient.getChainId();
19+
const cfg = getConfig(chainId);
20+
21+
// Load artifacts
22+
const artifactsRoot = path.resolve(__dirname, "..", "artifacts", "contracts");
23+
const aggArtifact = await readJson(path.resolve(artifactsRoot, "rewards", "RewardsAggregator.sol", "RewardsAggregator.json"));
24+
const mockErc20Artifact = await readJson(path.resolve(artifactsRoot, "mocks", "MockERC20.sol", "MockERC20.json"));
25+
const mockVaultArtifact = await readJson(path.resolve(artifactsRoot, "mocks", "MockERC4626Vault.sol", "MockERC4626Vault.json"));
26+
const mockFactoryArtifact = await readJson(path.resolve(artifactsRoot, "mocks", "MockSendEarnFactory.sol", "MockSendEarnFactory.json"));
27+
if (!aggArtifact?.abi || !aggArtifact?.bytecode || !mockErc20Artifact?.abi || !mockVaultArtifact?.abi || !mockFactoryArtifact?.abi) this.skip();
28+
29+
// 1) Underlying asset (6 decimals) and SuperToken wrapper
30+
const erc20Abi = mockErc20Artifact.abi as any[];
31+
const erc20Bytecode = (mockErc20Artifact.bytecode?.object ?? mockErc20Artifact.bytecode) as `0x${string}`;
32+
const hashUSDC = await walletClient.deployContract({ abi: erc20Abi, bytecode: erc20Bytecode, args: ["USDC", "USDC", 6], account: walletClient.account! });
33+
const receiptUSDC = await publicClient.waitForTransactionReceipt({ hash: hashUSDC });
34+
const usdc = receiptUSDC.contractAddress as `0x${string}` | null;
35+
if (!usdc) this.skip();
36+
37+
// Create wrapper via SuperTokenFactory
38+
// eslint-disable-next-line @typescript-eslint/no-var-requires
39+
const SuperTokenFactoryJson = await import("@superfluid-finance/ethereum-contracts/build/truffle/SuperTokenFactory.json");
40+
const factorySF = getContract({ address: cfg.superTokenFactory, abi: (SuperTokenFactoryJson as any).default.abi as any[], client: { public: publicClient, wallet: walletClient } });
41+
const { request: createReq, result: sendxRes } = await factorySF.simulate.createERC20Wrapper([usdc, 6, 1, cfg.wrapperName, cfg.wrapperSymbol], { account: walletClient.account! });
42+
const txCreate = await walletClient.writeContract(createReq);
43+
await publicClient.waitForTransactionReceipt({ hash: txCreate });
44+
const sendx = sendxRes as unknown as `0x${string}`;
45+
46+
// 2) Deploy mock SendEarnFactory and a valid vault
47+
const mockFactoryAbi = mockFactoryArtifact.abi as any[];
48+
const mockFactoryBytecode = (mockFactoryArtifact.bytecode?.object ?? mockFactoryArtifact.bytecode) as `0x${string}`;
49+
const hashF = await walletClient.deployContract({ abi: mockFactoryAbi, bytecode: mockFactoryBytecode, args: [], account: walletClient.account! });
50+
const receiptF = await publicClient.waitForTransactionReceipt({ hash: hashF });
51+
const factoryAddr = receiptF.contractAddress as `0x${string}`;
52+
const factoryC = getContract({ address: factoryAddr, abi: mockFactoryAbi, client: { public: publicClient, wallet: walletClient } });
53+
54+
const mockVaultAbi = mockVaultArtifact.abi as any[];
55+
const mockVaultBytecode = (mockVaultArtifact.bytecode?.object ?? mockVaultArtifact.bytecode) as `0x${string}`;
56+
const hashV = await walletClient.deployContract({ abi: mockVaultAbi, bytecode: mockVaultBytecode, args: [usdc, "vUSDC", "vUSDC", 1, 1], account: walletClient.account! });
57+
const receiptV = await publicClient.waitForTransactionReceipt({ hash: hashV });
58+
const vault = receiptV.contractAddress as `0x${string}`;
59+
60+
// Mark vault as SendEarn-approved
61+
const { request: setSE } = await factoryC.simulate.setIsSendEarn([vault, true], { account: walletClient.account! });
62+
await walletClient.writeContract(setSE);
63+
64+
// 3) Deploy RewardsAggregator
65+
const aggAbi = aggArtifact.abi as any[];
66+
const aggBytecode = (aggArtifact.bytecode?.object ?? aggArtifact.bytecode) as `0x${string}`;
67+
const hashAgg = await walletClient.deployContract({ abi: aggAbi, bytecode: aggBytecode, args: [sendx, factoryAddr, usdc, walletClient.account!.address], account: walletClient.account! });
68+
const receiptAgg = await publicClient.waitForTransactionReceipt({ hash: hashAgg });
69+
const aggregator = receiptAgg.contractAddress as `0x${string}` | null;
70+
if (!aggregator) this.skip();
71+
72+
const agg = getContract({ address: aggregator!, abi: aggAbi, client: { public: publicClient, wallet: walletClient } });
73+
74+
// 4) Configure high per-second rate (secondsPerYear=1) and pre-fund aggregator with SENDx
75+
// eslint-disable-next-line @typescript-eslint/no-var-requires
76+
const ISuperTokenJson = await import("@superfluid-finance/ethereum-contracts/build/truffle/ISuperToken.json");
77+
// eslint-disable-next-line @typescript-eslint/no-var-requires
78+
const IERC20Json = await import("@superfluid-finance/ethereum-contracts/build/truffle/IERC20.json");
79+
80+
await agg.write.setSecondsPerYear([1n], { account: walletClient.account! });
81+
82+
const underlying = getContract({ address: usdc!, abi: (IERC20Json as any).default.abi as any[], client: { public: publicClient, wallet: walletClient } });
83+
// Mint underlying to wallet for upgrade funding and deposit
84+
const minterAbi = [{ type: "function", name: "mint", stateMutability: "nonpayable", inputs: [{ name: "to", type: "address" }, { name: "amount", type: "uint256" }], outputs: [] }] as const;
85+
const minter = getContract({ address: usdc!, abi: minterAbi as any, client: { public: publicClient, wallet: walletClient } });
86+
87+
const fundUnderlying = 10_000_000_000n; // 10,000 USDC (6 decimals)
88+
await minter.write.mint([walletClient.account!.address, fundUnderlying]);
89+
90+
const superToken = getContract({ address: sendx, abi: (ISuperTokenJson as any).default.abi as any[], client: { public: publicClient, wallet: walletClient } });
91+
await underlying.write.approve([sendx, fundUnderlying], { account: walletClient.account! });
92+
await superToken.write.upgrade([fundUnderlying], { account: walletClient.account! });
93+
94+
// Transfer SENDx to aggregator so it can open flows
95+
const fundSendx = 5_000_000_000n; // 5,000 USDC worth in SENDx
96+
await superToken.write.transfer([aggregator!, fundSendx], { account: walletClient.account! });
97+
98+
// 5) Deposit underlying via aggregator: approve + depositAssets
99+
const depositAmount = 1_000_000n; // 1 USDC
100+
await underlying.write.approve([aggregator!, depositAmount], { account: walletClient.account! });
101+
102+
const { request: depReq } = await agg.simulate.depositAssets([vault, depositAmount], { account: walletClient.account! });
103+
const depHash = await walletClient.writeContract(depReq);
104+
await publicClient.waitForTransactionReceipt({ hash: depHash });
105+
106+
// 6) Verify CFA flow exists and equals expected per-second rate
107+
// flowRate = floor(depositAmount * 0.03) since secondsPerYear=1 and exchangeRate=1
108+
const expectedRate = (depositAmount * 3n) / 100n;
109+
110+
// Read CFA getFlow
111+
// eslint-disable-next-line @typescript-eslint/no-var-requires
112+
const IConstantFlowAgreementV1Json = await import("@superfluid-finance/ethereum-contracts/build/truffle/IConstantFlowAgreementV1.json");
113+
const cfa = getContract({ address: cfg.cfaV1, abi: (IConstantFlowAgreementV1Json as any).default.abi as any[], client: { public: publicClient } });
114+
const flowInfo = await cfa.read.getFlow([sendx, aggregator!, walletClient.account!.address]) as any[];
115+
const onchainRate = BigInt(flowInfo[3] ?? flowInfo[1] ?? 0); // ABI variants: result index differs across builds
116+
expect(onchainRate).to.eq(expectedRate);
117+
118+
// 7) Withdraw everything; expect flow to be deleted (rate -> 0)
119+
const { request: wReq } = await agg.simulate.withdrawAssets([vault, depositAmount, walletClient.account!.address], { account: walletClient.account! });
120+
const wHash = await walletClient.writeContract(wReq);
121+
await publicClient.waitForTransactionReceipt({ hash: wHash });
122+
123+
const flowAfter = await cfa.read.getFlow([sendx, aggregator!, walletClient.account!.address]) as any[];
124+
const rateAfter = BigInt(flowAfter[3] ?? flowAfter[1] ?? 0);
125+
expect(rateAfter).to.eq(0n);
126+
127+
// 8) Vault gating: invalid vault should revert
128+
const invalidVault = `0x${"b".repeat(40)}` as `0x${string}`;
129+
let reverted = false;
130+
try {
131+
await agg.simulate.depositAssets([invalidVault, 1n], { account: walletClient.account! });
132+
} catch { reverted = true; }
133+
expect(reverted).to.eq(true);
134+
});
135+
});

test/rewards.manager.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ async function resolveFactory(chainId: number): Promise<`0x${string}` | null> {
6868
return null;
6969
}
7070

71+
<<<<<<< HEAD
7172
describe("RewardsManager (Base fork)", () => {
73+
=======
74+
const describeIntegration = process.env.RUN_INTEGRATION === "true" ? describe : describe.skip;
75+
76+
describeIntegration("RewardsManager (Base fork)", () => {
77+
>>>>>>> 1e31d976 (rewards: depositVaultShares pre-NAV; add tests)
7278
it("deploys and can call syncVault (env-gated)", async function () {
7379
const publicClient = await hre.viem.getPublicClient();
7480
const [walletClient] = await hre.viem.getWalletClients();

0 commit comments

Comments
 (0)