Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions TEMP_SEND_EARN_REWARDS_AGGREGATOR_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SendEarnRewards Aggregator – Temporary Design Reminder (delete later)

Status: Temporary notes to guide implementation decisions. Safe to delete after alignment.

## Short answer

Yes, it makes sense to front SendEarn with a separate ERC4626 “aggregator” vault that:
- Holds SendEarn vault shares in its own custody, so accounting is authoritative and actions are visible
- Exposes the standard ERC4626 interface (deposit/withdraw) for composability
- Adds a non-standard path to onboard existing SendEarn shares (transfer them in)
- Avoids upgrading SendEarn (no user migrations), preserves ERC4626 utility for integrators, and lets you aggregate value across vaults you control

## Desired behavior (restated)
- 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.
- Calling `withdraw` on the aggregator should withdraw from the SendEarn vault (and underlying), reducing the aggregator’s SendEarn share holdings accordingly.
- 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.

This mirrors SendEarn’s super-hook behavior while centralizing custody of the SendEarn shares in the aggregator.

## Why a separate ERC4626 wrapper-vault is a good fit
- 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.
- Composability: integrators use ERC4626 deposit/withdraw; aggregator handles routing/bubbling into SendEarn and custody of SendEarn shares.
- 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.
- No migrations: no need to upgrade SendEarn or require user migrations. Users deposit into the aggregator, or transfer their existing shares into it.
- 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.

## Design details to get right
- Per-user accounting: track per-user, per-vault shares; compute underlying-equivalent assets via `convertToAssets(shares)` for flows and displays.
- 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`.
- Existing share intake: `depositVaultShares(vault, shares)`:
- `transferFrom` SendEarn shares into the aggregator
- `assetsEq = vault.convertToAssets(shares)`
- mint aggregator shares equal to `assetsEq`
- update per-vault/user ledger and flows
- 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.
- Multi-vault aggregation & exits:
- 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.
- 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)`.

## Tradeoffs vs upgrading SendEarn
- Upgrading SendEarn:
- Pros: native flows; fewer moving parts
- Cons: migration/user coordination, more risk, compatibility concerns
- Aggregator ERC4626:
- Pros: migration-free; preserves ERC4626 composability; easy to add share-intake; central policy control
- Cons: one more contract; exit routing policy needed across multi-vault holdings

## Recommended shape (concrete)
- Keep aggregator as ERC4626 with `asset()` = underlying
- Retain super-hook routing so ERC4626 deposit/withdraw bubble into/out of the chosen SendEarn vault; hold SendEarn shares in aggregator
- Add `depositVaultShares(vault, shares)` onboarding path for existing SendEarn share-holders. Consider optional `withdrawVaultShares` for share exits
- Pick a clear withdraw policy (mapping-based to start) and document it
- Flows computed from `vault.convertToAssets(userShares)`; update flows on each action

## Open questions / next choices
- Do we want a share-return exit path (`withdrawVaultShares`) from the aggregator alongside ERC4626 withdraw?
- Which exit policy do we standardize on for ERC4626 withdraw across multiple held vaults (mapping-based vs pro-rata)?
- Should aggregator shares be non-transferable (simpler for flows) or transferable (more utility for integrations)? Current default: non-transferable

---
Temporary reminder; safe to delete after alignment.
45 changes: 43 additions & 2 deletions contracts/rewards/SendEarnRewards.sol
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,18 @@ contract SendEarnRewards is ERC4626, AccessControl, ReentrancyGuard {
}
}
}
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; }
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) {
uint256 supply = totalSupply();
uint256 total = totalAssets();
if (supply == 0 || total == 0) return assets;
return Math.mulDiv(assets, supply, total, rounding);
}
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) {
uint256 supply = totalSupply();
uint256 total = totalAssets();
if (supply == 0) return 0;
return Math.mulDiv(shares, total, supply, rounding);
}

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


// Accept existing SendEarn vault shares and mint aggregator shares by NAV
function depositVaultShares(address vault, uint256 shares) external nonReentrant {
require(shares > 0, "shares");
require(factory.isSendEarn(vault), "not SendEarn");
require(IERC4626(vault).asset() == address(asset()), "asset mismatch");
if (!_isActiveVault[vault]) { _isActiveVault[vault] = true; _activeVaults.push(vault); }

// Compute assets-equivalent of incoming vault shares using the vault's ERC4626 conversion
uint256 assetsEq = IERC4626(vault).convertToAssets(shares);

// Snapshot pre-ingestion NAV to align with previewDeposit semantics
uint256 supplyBefore = totalSupply();
uint256 assetsBefore = totalAssets();
uint256 minted;
if (supplyBefore == 0 || assetsBefore == 0) {
minted = assetsEq;
} else {
minted = Math.mulDiv(assetsEq, supplyBefore, assetsBefore, Math.Rounding.Floor);
}

// Pull vault shares from user into the aggregator and attribute to sender
IERC20(vault).safeTransferFrom(msg.sender, address(this), shares);
_userUnderlyingShares[msg.sender][vault] += shares;

// Mint aggregator shares computed from pre-ingestion NAV
_mint(msg.sender, minted);

emit Deposited(msg.sender, vault, assetsEq, shares);
}

// Resolve the SendEarn vault for a given account.
function _resolveVaultFor(address who) internal view returns (address v) {
v = factory.affiliates(who);
Expand Down
12 changes: 5 additions & 7 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ dotenv.config();
const config: HardhatUserConfig = {
solidity: "0.8.28",
networks: {
// base mainnet network
// Local in-process network for tests (no forking unless FORK_URL is set)
hardhat: {
chainId: 8453,
chainId: 31337,
hardfork: "cancun",
forking: {
url: "https://mainnet.base.org",
},
forking: process.env.FORK_URL ? { url: process.env.FORK_URL } : undefined,
},
anvil: {
url: "http://127.0.0.1:8546",
Expand All @@ -23,12 +21,12 @@ const config: HardhatUserConfig = {
sepolia: {
url: "https://sepolia.base.org",
chainId: 84532,
accounts: [process.env.EOA_DEPLOYER!],
accounts: process.env.EOA_DEPLOYER ? [process.env.EOA_DEPLOYER] : [],
},
base: {
url: "https://mainnet.base.org",
chainId: 8453,
accounts: [process.env.EOA_DEPLOYER!],
accounts: process.env.EOA_DEPLOYER ? [process.env.EOA_DEPLOYER] : [],
},
},
etherscan: {
Expand Down
135 changes: 135 additions & 0 deletions test/rewards.aggregator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { expect } from "chai";
import hre from "hardhat";
import { getConfig } from "../config/superfluid";
import { getContract } from "viem";
import fs from "fs/promises";
import path from "node:path";

async function readJson(file: string): Promise<any | null> {
try { return JSON.parse(await fs.readFile(file, "utf8")); } catch { return null; }
}

describe("RewardsAggregator (CFA flows)", function () {
it("creates/updates CFA flow on deposit/withdraw (no env)", async function () {
const publicClient = await hre.viem.getPublicClient();
const [walletClient] = await hre.viem.getWalletClients();
if (!walletClient) this.skip();

const chainId = await publicClient.getChainId();
const cfg = getConfig(chainId);

// Load artifacts
const artifactsRoot = path.resolve(__dirname, "..", "artifacts", "contracts");
const aggArtifact = await readJson(path.resolve(artifactsRoot, "rewards", "RewardsAggregator.sol", "RewardsAggregator.json"));
const mockErc20Artifact = await readJson(path.resolve(artifactsRoot, "mocks", "MockERC20.sol", "MockERC20.json"));
const mockVaultArtifact = await readJson(path.resolve(artifactsRoot, "mocks", "MockERC4626Vault.sol", "MockERC4626Vault.json"));
const mockFactoryArtifact = await readJson(path.resolve(artifactsRoot, "mocks", "MockSendEarnFactory.sol", "MockSendEarnFactory.json"));
if (!aggArtifact?.abi || !aggArtifact?.bytecode || !mockErc20Artifact?.abi || !mockVaultArtifact?.abi || !mockFactoryArtifact?.abi) this.skip();

// 1) Underlying asset (6 decimals) and SuperToken wrapper
const erc20Abi = mockErc20Artifact.abi as any[];
const erc20Bytecode = (mockErc20Artifact.bytecode?.object ?? mockErc20Artifact.bytecode) as `0x${string}`;
const hashUSDC = await walletClient.deployContract({ abi: erc20Abi, bytecode: erc20Bytecode, args: ["USDC", "USDC", 6], account: walletClient.account! });
const receiptUSDC = await publicClient.waitForTransactionReceipt({ hash: hashUSDC });
const usdc = receiptUSDC.contractAddress as `0x${string}` | null;
if (!usdc) this.skip();

// Create wrapper via SuperTokenFactory
// eslint-disable-next-line @typescript-eslint/no-var-requires
const SuperTokenFactoryJson = await import("@superfluid-finance/ethereum-contracts/build/truffle/SuperTokenFactory.json");
const factorySF = getContract({ address: cfg.superTokenFactory, abi: (SuperTokenFactoryJson as any).default.abi as any[], client: { public: publicClient, wallet: walletClient } });
const { request: createReq, result: sendxRes } = await factorySF.simulate.createERC20Wrapper([usdc, 6, 1, cfg.wrapperName, cfg.wrapperSymbol], { account: walletClient.account! });
const txCreate = await walletClient.writeContract(createReq);
await publicClient.waitForTransactionReceipt({ hash: txCreate });
const sendx = sendxRes as unknown as `0x${string}`;

// 2) Deploy mock SendEarnFactory and a valid vault
const mockFactoryAbi = mockFactoryArtifact.abi as any[];
const mockFactoryBytecode = (mockFactoryArtifact.bytecode?.object ?? mockFactoryArtifact.bytecode) as `0x${string}`;
const hashF = await walletClient.deployContract({ abi: mockFactoryAbi, bytecode: mockFactoryBytecode, args: [], account: walletClient.account! });
const receiptF = await publicClient.waitForTransactionReceipt({ hash: hashF });
const factoryAddr = receiptF.contractAddress as `0x${string}`;
const factoryC = getContract({ address: factoryAddr, abi: mockFactoryAbi, client: { public: publicClient, wallet: walletClient } });

const mockVaultAbi = mockVaultArtifact.abi as any[];
const mockVaultBytecode = (mockVaultArtifact.bytecode?.object ?? mockVaultArtifact.bytecode) as `0x${string}`;
const hashV = await walletClient.deployContract({ abi: mockVaultAbi, bytecode: mockVaultBytecode, args: [usdc, "vUSDC", "vUSDC", 1, 1], account: walletClient.account! });
const receiptV = await publicClient.waitForTransactionReceipt({ hash: hashV });
const vault = receiptV.contractAddress as `0x${string}`;

// Mark vault as SendEarn-approved
const { request: setSE } = await factoryC.simulate.setIsSendEarn([vault, true], { account: walletClient.account! });
await walletClient.writeContract(setSE);

// 3) Deploy RewardsAggregator
const aggAbi = aggArtifact.abi as any[];
const aggBytecode = (aggArtifact.bytecode?.object ?? aggArtifact.bytecode) as `0x${string}`;
const hashAgg = await walletClient.deployContract({ abi: aggAbi, bytecode: aggBytecode, args: [sendx, factoryAddr, usdc, walletClient.account!.address], account: walletClient.account! });
const receiptAgg = await publicClient.waitForTransactionReceipt({ hash: hashAgg });
const aggregator = receiptAgg.contractAddress as `0x${string}` | null;
if (!aggregator) this.skip();

const agg = getContract({ address: aggregator!, abi: aggAbi, client: { public: publicClient, wallet: walletClient } });

// 4) Configure high per-second rate (secondsPerYear=1) and pre-fund aggregator with SENDx
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ISuperTokenJson = await import("@superfluid-finance/ethereum-contracts/build/truffle/ISuperToken.json");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const IERC20Json = await import("@superfluid-finance/ethereum-contracts/build/truffle/IERC20.json");

await agg.write.setSecondsPerYear([1n], { account: walletClient.account! });

const underlying = getContract({ address: usdc!, abi: (IERC20Json as any).default.abi as any[], client: { public: publicClient, wallet: walletClient } });
// Mint underlying to wallet for upgrade funding and deposit
const minterAbi = [{ type: "function", name: "mint", stateMutability: "nonpayable", inputs: [{ name: "to", type: "address" }, { name: "amount", type: "uint256" }], outputs: [] }] as const;
const minter = getContract({ address: usdc!, abi: minterAbi as any, client: { public: publicClient, wallet: walletClient } });

const fundUnderlying = 10_000_000_000n; // 10,000 USDC (6 decimals)
await minter.write.mint([walletClient.account!.address, fundUnderlying]);

const superToken = getContract({ address: sendx, abi: (ISuperTokenJson as any).default.abi as any[], client: { public: publicClient, wallet: walletClient } });
await underlying.write.approve([sendx, fundUnderlying], { account: walletClient.account! });
await superToken.write.upgrade([fundUnderlying], { account: walletClient.account! });

// Transfer SENDx to aggregator so it can open flows
const fundSendx = 5_000_000_000n; // 5,000 USDC worth in SENDx
await superToken.write.transfer([aggregator!, fundSendx], { account: walletClient.account! });

// 5) Deposit underlying via aggregator: approve + depositAssets
const depositAmount = 1_000_000n; // 1 USDC
await underlying.write.approve([aggregator!, depositAmount], { account: walletClient.account! });

const { request: depReq } = await agg.simulate.depositAssets([vault, depositAmount], { account: walletClient.account! });
const depHash = await walletClient.writeContract(depReq);
await publicClient.waitForTransactionReceipt({ hash: depHash });

// 6) Verify CFA flow exists and equals expected per-second rate
// flowRate = floor(depositAmount * 0.03) since secondsPerYear=1 and exchangeRate=1
const expectedRate = (depositAmount * 3n) / 100n;

// Read CFA getFlow
// eslint-disable-next-line @typescript-eslint/no-var-requires
const IConstantFlowAgreementV1Json = await import("@superfluid-finance/ethereum-contracts/build/truffle/IConstantFlowAgreementV1.json");
const cfa = getContract({ address: cfg.cfaV1, abi: (IConstantFlowAgreementV1Json as any).default.abi as any[], client: { public: publicClient } });
const flowInfo = await cfa.read.getFlow([sendx, aggregator!, walletClient.account!.address]) as any[];
const onchainRate = BigInt(flowInfo[3] ?? flowInfo[1] ?? 0); // ABI variants: result index differs across builds
expect(onchainRate).to.eq(expectedRate);

// 7) Withdraw everything; expect flow to be deleted (rate -> 0)
const { request: wReq } = await agg.simulate.withdrawAssets([vault, depositAmount, walletClient.account!.address], { account: walletClient.account! });
const wHash = await walletClient.writeContract(wReq);
await publicClient.waitForTransactionReceipt({ hash: wHash });

const flowAfter = await cfa.read.getFlow([sendx, aggregator!, walletClient.account!.address]) as any[];
const rateAfter = BigInt(flowAfter[3] ?? flowAfter[1] ?? 0);
expect(rateAfter).to.eq(0n);

// 8) Vault gating: invalid vault should revert
const invalidVault = `0x${"b".repeat(40)}` as `0x${string}`;
let reverted = false;
try {
await agg.simulate.depositAssets([invalidVault, 1n], { account: walletClient.account! });
} catch { reverted = true; }
expect(reverted).to.eq(true);
});
});
6 changes: 6 additions & 0 deletions test/rewards.manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ async function resolveFactory(chainId: number): Promise<`0x${string}` | null> {
return null;
}

<<<<<<< HEAD
describe("RewardsManager (Base fork)", () => {
=======
const describeIntegration = process.env.RUN_INTEGRATION === "true" ? describe : describe.skip;

describeIntegration("RewardsManager (Base fork)", () => {
>>>>>>> 1e31d976 (rewards: depositVaultShares pre-NAV; add tests)
it("deploys and can call syncVault (env-gated)", async function () {
const publicClient = await hre.viem.getPublicClient();
const [walletClient] = await hre.viem.getWalletClients();
Expand Down
Loading