Skip to content

Commit d89f73d

Browse files
Tests: prepare for ERC4626 composability (NAV-based); remove 1:1 assumptions
- Deposit mints per previewDeposit (NAV), not 1:1 - Withdraw burns per previewWithdraw and updates only resolved vault - Transfers do not modify per-user ledgers - totalAssets sums across tracked vaults - Ingesting SendEarn shares mints aggregator shares per NAV Test plan: - Run: bunx hardhat test test/rewards/SendEarnRewards.erc4626.spec.ts
1 parent e9ede2e commit d89f73d

File tree

2 files changed

+84
-14
lines changed

2 files changed

+84
-14
lines changed

docs/rewards-aggregator-erc4626.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,53 @@ Status: Spec only (docs-first). Implementation and tests will follow in separate
1414
- Active vaults (wrapper-wide): first time a deposit is made into a vault, it’s added to `_activeVaults` for view-only `totalAssets()`.
1515
- 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).
1616

17-
## Asset and conversions
17+
## Asset, conversions, and NAV-based accounting (v2.0)
18+
- ERC4626 aggregator with `asset()` equal to the underlying asset used by all SendEarn vaults (e.g., USDC).
19+
- NAV-based share pricing (standard ERC4626 math): aggregator shares represent a pro‑rata claim on the total aggregated assets held via SendEarn vault shares.
20+
- Conversions (no 1:1 simplification):
21+
- `totalAssets()` equals the sum, over all tracked vaults `v`, of `IERC4626(v).convertToAssets(IERC4626(v).balanceOf(address(this)))`.
22+
- `_convertToShares(assets)` and `_convertToAssets(shares)` use standard ERC4626 formulas based on current `totalAssets()` and `totalSupply()`.
23+
24+
### Deposit interfaces
25+
There are two complementary ways to contribute value:
26+
27+
1) Ingest existing SendEarn vault shares (non‑standard): `depositVaultShares(vault, shares)`
28+
- Preconditions: `factory.isSendEarn(vault)` and `IERC4626(vault).asset() == asset()`.
29+
- Flow:
30+
1) Pull `shares` from the user into the aggregator (transferFrom)
31+
2) `assetsEq = IERC4626(vault).convertToAssets(shares)`
32+
3) Mint aggregator shares using NAV: `minted = _convertToShares(assetsEq, …)`
33+
4) Per‑user ledger: `_userUnderlyingShares[user][vault] += shares`
34+
5) Track vault in `_activeVaults` if first use
35+
6) Emit `Deposited(user, vault, assetsEq, shares)`
36+
37+
2) Standard ERC4626 deposit of underlying: `deposit(assets, receiver)`
38+
- Resolution: `vault = affiliates(receiver)` if non‑zero else `SEND_EARN()`
39+
- Flow:
40+
1) Mint aggregator shares using NAV: `minted = _convertToShares(assets, …)`
41+
2) Aggregator deposits `assets` into `vault`, receiving `vaultShares`
42+
3) Per‑user ledger: `_userUnderlyingShares[receiver][vault] += vaultShares`
43+
4) Track vault in `_activeVaults` if first use
44+
5) Emit `Deposited(receiver, vault, assets, vaultShares)`
45+
46+
### Withdraw and Redeem (single‑vault, no loops)
47+
- `withdraw(assets, receiver, owner)`
48+
- `vault = affiliates(owner)` if non‑zero else `SEND_EARN()`
49+
- `sharesNeeded = IERC4626(vault).previewWithdraw(assets)`
50+
- Require `_userUnderlyingShares[owner][vault] >= sharesNeeded`
51+
- Redeem `sharesNeeded` from `vault` to the aggregator, then send `assets` to `receiver`
52+
- Burn aggregator shares using NAV: `_convertToShares(assets, …)`
53+
- `_userUnderlyingShares[owner][vault] -= sharesNeeded`
54+
- Emit `Withdrawn(owner, vault, assets, sharesNeeded)`
55+
56+
- `redeem(shares, receiver, owner)`
57+
- Resolve `vault` as above
58+
- Redeem underlying vault shares sufficient to produce `assetsOut`, send to `receiver`
59+
- Update per‑user ledger and burn aggregator shares using NAV
60+
61+
### Transfers
62+
- ERC20 transfers of aggregator shares DO NOT modify per‑user underlying ledgers.
63+
- Flows (CFA) are updated only on deposit/withdraw/redeem (see CFA v2.1).
1864
- The aggregator’s `asset()` equals the underlying asset used by all routed SendEarn vaults (e.g., USDC).
1965
- 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`.
2066

@@ -55,9 +101,10 @@ All routed targets MUST satisfy:
55101

56102
## Total assets (view-only)
57103
- `totalAssets()` sums across wrapper-held positions:
58-
- For each tracked vault v in `_activeVaults`:
104+
- For each tracked vault `v` in `_activeVaults`:
59105
- `assets += IERC4626(v).convertToAssets(IERC4626(v).balanceOf(address(this)))`
60106
- This is a view-only iteration. State-changing flows remain single-vault without loops.
107+
- Conversions use NAV (no 1:1 shortcut).
61108

62109
## CFA streaming integration (v2.1) — Professional spec
63110

test/rewards/SendEarnRewards.erc4626.spec.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ describe("SendEarnRewards v2 (ERC4626 only; streaming deferred)", () => {
6161
await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] });
6262

6363
const assets = 200n * 10n ** 18n;
64+
const expMint = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "previewDeposit", args: [assets] });
6465
await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "deposit", args: [assets, a] });
6566

66-
// Wrapper shares 1:1
67+
// Wrapper shares minted per ERC4626 NAV (not 1:1)
6768
const bal = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] });
68-
expect(bal).to.equal(assets);
69+
expect(bal).to.equal(expMint);
6970

7071
// Underlying shares recorded
7172
const underlyingShares: bigint = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultA.address] });
@@ -100,10 +101,12 @@ describe("SendEarnRewards v2 (ERC4626 only; streaming deferred)", () => {
100101

101102
// Set affiliate back to vaultA and withdraw 40 (single vault)
102103
await userA.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [a, vaultA.address] });
104+
const burnShares = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "previewWithdraw", args: [40n * 10n ** 18n] });
105+
const balBefore = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] });
103106
await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "withdraw", args: [40n * 10n ** 18n, a, a] });
104107

105108
const balAfter = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] });
106-
expect(balAfter).to.equal(depositAmt - 40n * 10n ** 18n);
109+
expect(balBefore - balAfter).to.equal(burnShares);
107110
});
108111

109112
it("totalAssets equals sum over tracked vaults convertToAssets(wrapper-held shares)", async () => {
@@ -131,7 +134,7 @@ describe("SendEarnRewards v2 (ERC4626 only; streaming deferred)", () => {
131134
expect(total).to.equal((assetsA as bigint) + (assetsB as bigint));
132135
});
133136

134-
it("transfers re-attribute underlying shares proportionally across sender's active vaults", async () => {
137+
it("transfers do not modify per-user underlying ledgers", async () => {
135138
const { pub, deployer, userA, userB, erc20, vaultA, factory, rewards } = await deployFixture();
136139
const a = userA.account!.address as `0x${string}`;
137140
const b = userB.account!.address as `0x${string}`;
@@ -151,18 +154,38 @@ describe("SendEarnRewards v2 (ERC4626 only; streaming deferred)", () => {
151154
const xfer = 120n * 10n ** 18n;
152155
await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "transfer", args: [b, xfer] });
153156

154-
const moved = (fromUnderlyingBefore as bigint) * xfer / (senderSharesBefore as bigint);
155157
const fromUnderlyingAfter = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultA.address] });
156158
const toUnderlyingAfter = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [b, vaultA.address] });
157159

158-
expect((fromUnderlyingBefore as bigint) - (fromUnderlyingAfter as bigint)).to.equal(moved);
159-
expect(toUnderlyingAfter).to.equal(moved);
160+
// No change to per-user ledgers due to transfer
161+
expect(fromUnderlyingAfter).to.equal(fromUnderlyingBefore);
162+
expect(toUnderlyingAfter).to.equal(0n);
163+
});
164+
it("ingests existing SendEarn shares and mints aggregator shares per NAV", async () => {
165+
const { pub, deployer, userA, erc20, vaultA, factory, rewards } = await deployFixture();
166+
const a = userA.account!.address as `0x${string}`;
167+
168+
// User acquires vaultA shares directly
169+
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 });
170+
await userA.writeContract({ address: erc20.address, abi: (await hre.artifacts.readArtifact("ERC20Mintable")).abi as any, functionName: "approve", args: [vaultA.address, 600n * 10n ** 18n] });
171+
const depositToVault = 250n * 10n ** 18n;
172+
await userA.writeContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "deposit", args: [depositToVault, a] });
173+
const userVaultShares = await pub.readContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "balanceOf", args: [a] });
174+
175+
// Approve aggregator to take vault shares
176+
await userA.writeContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "approve", args: [rewards.address, userVaultShares] });
177+
178+
// Expected aggregator shares using NAV
179+
const assetsEq = await pub.readContract({ address: vaultA.address, abi: (await hre.artifacts.readArtifact("ERC4626TestVault")).abi as any, functionName: "convertToAssets", args: [userVaultShares] });
180+
const expMintAgg = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "previewDeposit", args: [assetsEq] });
181+
182+
// Ingest shares
183+
await userA.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "depositVaultShares", args: [vaultA.address, userVaultShares] });
160184

161-
// Set affiliates(userB)=vaultA then withdraw a portion successfully
162-
await userB.writeContract({ address: factory.address, abi: (await hre.artifacts.readArtifact("SendEarnFactoryAffiliatesMock")).abi as any, functionName: "setAffiliate", args: [b, vaultA.address] });
163-
await userB.writeContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "withdraw", args: [20n * 10n ** 18n, b, b] });
185+
const aggBal = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [a] });
186+
expect(aggBal).to.equal(expMintAgg);
164187

165-
const toBal = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "balanceOf", args: [b] });
166-
expect(toBal).to.equal(xfer - 20n * 10n ** 18n);
188+
const recordedShares = await pub.readContract({ address: rewards.address, abi: (await hre.artifacts.readArtifact("SendEarnRewards")).abi as any, functionName: "userUnderlyingShares", args: [a, vaultA.address] });
189+
expect(recordedShares).to.equal(userVaultShares);
167190
});
168191
});

0 commit comments

Comments
 (0)