A multi-asset vault system that lets users deposit and withdraw ERC‑20 tokens at any time subject to liquidity, while generating yield via Aave v3. Depositors always withdraw principal only. The yield is tracked separately and can be harvested only by admin. Harvested yield can optionally be distributed (by a special role) to a random eligible holder using Chainlink VRF v2.5. Created for demonstration purposes.
This repo contains three main components:
HTokenVault.sol: a per‑asset, Aave‑backed, upgradeable ERC‑4626 vault (one vault per ERC‑20 token). Inspired by Aave's ATokenVault.MultiAssetVaultRouter.sol: a unified interface that routes deposits and withdrawals to the correct vault and supports adding and removing of assets by admin.YieldRaffleManager.sol: integrates Chainlink VRF v2.5 to pick a random winner among current vault share holders and instruct the router to pay the full harvested yield to that winner.
Yield accrual is demonstrated on Ethereum mainnet fork (WETH on Aave v3; fork pinned at block 21_500_000 for reproducibility). The integration tests prove accrual by depositing, warping time and advancing blocks, triggering Aave’s reserve update, then asserting claimableYield() increases and can be harvested.
Key tests:
- Happy-path deposit / withdraw on fork:
testFork_depositAndWithdraw_onAavePool. - Yield accrual:
testFork_yieldAccruesAndWithdrawYield(deposit → warp time + reserve update → assertclaimableYield()> 0, harvest viawithdrawYield). - Router + admin harvest:
testFork_routerDeposit_yieldAccrues_adminHarvests_depositorWithdraws. - VRF (mock coordinator, request → fulfill → finalize → router pays winner):
test_integration_multipleVaults_multipleUsers_rafflePaysDifferentWinners. - Demonstrations (yield growth over blocks):
testFork_yieldAccruesInRealTime_demonstration,testFork_yieldAccruesWithMultipleUsers_demonstration.
The foundation of the design is ERC‑4626 (Tokenized Vaults). Each HTokenVault is a standard ERC‑4626 vault where:
- Users deposit an underlying ERC‑20 asset (e.g., USDC).
- Users receive vault shares (ERC‑20) representing their claim on principal only.
- Users can exit via:
withdraw(assets, receiver, owner), asset‑denominated exitredeem(shares, receiver, owner), share‑denominated exit
This repo uses OpenZeppelin's ERC4626Upgradeable implementation.
- ERC‑4626, vault interface and share / accounting conventions.
- ERC‑20, underlying asset + vault shares.
- EIP‑1967 + UUPS, upgradeability pattern for
HTokenVault. - ERC‑7575ish metadata (non‑full implementation):
HTokenVault.share()returnsaddress(this)(vault is its own share token).MultiAssetVaultRouter.vault(asset)returns the per‑asset vault address.
Note: this is not a full ERC‑7575 implementation (see Multi‑asset support below).
Users can interact either:
- directly with an
HTokenVault, or - via the
MultiAssetVaultRouterunified interface.
Routing
I have not forced all user interactions through the router. Direct vault access has been maintained in order to ensure that vaults remain independently usable for composability, and so that users can always directly exit (via withdraw / redeem), should another layer break. However, deposit / mint can be disabled by admin.
Withdrawals and liquidity
The guarantee “principal always withdrawable at any time” is limited only by Aave reserve liquidity (reflected in maxRedeem). In production, an optional “withdraw as aToken” fallback could be added to guarantee exit even during temporary underlying liquidity shortages.
Deposit and withdraw diagram
Only admin can register new assets / vaults and enable or disable deposits for an asset.
Add and remove asset diagram
The Chainlink VRF v2.5 integration supports harvesting each vault's yield and sending it to a randomly selected eligible winner.
VRF overview diagram
DEFAULT_ADMIN_ROLE- Authorizes UUPS upgrades
- General admin authority
YIELD_MANAGER_ROLE- The only role allowed to harvest yield via
withdrawYield(to, amount)
- The only role allowed to harvest yield via
- Deposit gating role
- Deposits / mints can be enabled / disabled at the vault level so
remove tokencannot be bypassed by direct vault calls. - Implemented as
DEPOSIT_MANAGER_ROLE(typically granted to the router).
- Deposits / mints can be enabled / disabled at the vault level so
DEFAULT_ADMIN_ROLE- Can add/remove/enable/disable assets
- Can manually call
withdrawYield(asset, to, amount)(admin override)
RAFFLE_CALLER_ROLE(if enabled)- Allows
YieldRaffleManagerto instruct the router to pay harvested yield to a chosen winner
- Allows
- Holds VRF configuration and request state
- Requests randomness and computes the winner
- Does not directly harvest from vaults; it instructs the router to do so
Deploy one vault per asset; the router registers each vault via addToken(asset, vault).
On each vault, grant the router YIELD_MANAGER_ROLE (so it can harvest yield) and DEPOSIT_MANAGER_ROLE (so it can gate deposits via setDepositsEnabled).
Grant RAFFLE_CALLER_ROLE on the router to the YieldRaffleManager (so it can call withdrawYieldToWinner).
Given the requirement that "only the admin or a designated role can harvest or extract accumulated yield", a core design decision is that depositors do not receive any yield. Yield is treated as an admin-controlled value that remains in the vault until harvested and is accounted for separately as follows.
The vault holds Aave aTokens. As interest accrues, aToken.balanceOf(vault) increases. That increase is "yield" and is excluded from the ERC‑4626 share pricing.
Conceptually:
claimableYield = accumulatedYield + max(aTokenNow - lastVaultBalance, 0)totalAssets = aTokenNow - claimableYield
This keeps ERC‑4626 share accounting tied to the principal only. This is implemented by overriding OpenZeppelin's ERC4626Upgradeable.
To implement this with OZ ERC‑4626:
totalAssets()is overridden to return principal only (excluding reserved yield).- The following ERC‑4626 internal hooks are overridden to integrate with Aave and to checkpoint yield:
_deposit(...)accrues yield, supplies to Aave, mints shares, syncs checkpoint._withdraw(...)accrues yield, burns shares, withdraws from Aave, syncs checkpoint.
The public ERC‑4626 entry points (deposit / mint / withdraw / redeem) remain standard (as per OZ comments) and route into these hooks.
OpenZeppelin v5 ERC‑4626 uses virtual shares and virtual assets by default (via _decimalsOffset() and related logic). That mechanism already mitigates the classic donation / inflation attack where an attacker mints cheap shares by donating assets and then dilutes the first real depositor. This vault uses OZ’s ERC4626Upgradeable, so it gets that protection without extra code.
In addition, and following ATokenVault.sol's approach, the vault supports an optional minInitialDeposit (set at initialization, or 0 to disable). When minInitialDeposit is non-zero, the first deposit (when totalSupply() == 0) must be at least that amount; otherwise the deposit reverts with MinInitialDepositRequired(). So here we have:
- OZ’s built-in protection, virtual shares / assets prevent donation / inflation abuse of the share price.
- Optional initial minimum,
minInitialDepositenforces a minimum size for the first deposit (e.g. 1e18 or 1000e6 for the asset’s decimals). Used to avoid opening the vault with a dust-sized first deposit or to enforce a policy (e.g. “vault opens only once there is meaningful liquidity”). If not needed,minInitialDepositcan be set to 0.
This system supports multiple ERC‑20 assets via a unified interface:
MultiAssetVaultRouterexposes:deposit(asset, assetsAmount, receiver)mint(asset, shares, receiver)withdraw(asset, assetsAmount, receiver, owner)redeem(asset, shares, receiver, owner)- plus admin methods to add / remove / enable / disable assets
Assets can be added (register asset -> vault) or removed (disable deposits while keeping withdrawals possible) without changing the vault logic.
In selecting the approach for one unified interface, I considered ERC-7575 Multi-Asset ERC-4626 Vaults. This implies a single fungible share token across multiple assets, which requires cross‑asset pricing / conversion (via pipes) as well as liquidity balancing / valuation assumptions. For simplicity, this repo intentionally keeps standard ERC‑4626 behavior per token and does not implement any cross‑asset valuation logic, which would add additional complexity (and risk surface) beyond the initial scope of the requirements. A future version of the project could explore the full ERC‑7575 implementation, noting Centrifuge's reference design.
However, an ERC-7575ish approach has been taken to help with multi-asset discoverability:
MultiAssetVaultRouter.vault(asset)gives an explicit on-chain mapping from an ERC‑20 asset to its vault instance;HTokenVault.share()makes it explicit what the share token is.
Chainlink VRF v2.5 is integrated to support a random yield distribution mechanism.
The flow is request → fulfill (store randomness) → finalize (select winner + router pays):
- Request — Admin (or
RAFFLE_ADMIN_ROLE) callsrequestYieldRaffle(asset); the manager requests randomness from VRF. - Fulfill — VRF callback
fulfillRandomWords(requestId, randomWords)stores the random word and marks the request fulfilled. - Finalize — Anyone calls
finalizeYieldRaffle(requestId); the manager selects a winner among current share holders (share-weighted, excludingexcludedRecipient) and callsrouter.withdrawYieldToWinner(asset, winner); the router harvestsclaimableYield()and pays the winner.
Sybil resistance is addressed by weighting the draw by share balance (“1 share = 1 ticket”).
Note: Local testing uses the VRF coordinator mock.
This implementation takes inspiration from Aave's ATokenVault.sol but intentionally differs:
- No meta-transactions / signatures (no EIP‑712 deposit / withdraw signatures).
- No rewards claiming (no incentives controller plumbing).
- Simplified surface area: focuses on core requirements (principal withdrawable, yield tracked and extractable by role).
- Router-based multi-asset support.
- Explicit yield separation.
- Optional raffle.
The design and yield logic rely on the following assumptions. Violations may break accounting or security.
- aToken balance growth is yield. The vault treats any increase in
aToken.balanceOf(vault)(after checkpointing) as accrued yield. I assume the only source of that increase is Aave interest. Direct transfers of aToken or underlying into the vault would be misclassified (as yield or principal) and are not supported. - Checkpointing is complete.
lastVaultBalanceandaccumulatedYieldare updated on every deposit, withdrawal, and yield harvest. I assume no other code path changes the vault’s aToken balance; otherwiseclaimableYieldandtotalAssetswould be wrong. - No rebasing or fee-on-transfer. The underlying (and thus the aToken) is assumed to be a standard ERC‑20: no rebasing, no fee-on-transfer, and no hooks that change balances outside of the vault’s own deposit / withdraw / harvest flows. Rebasing or fee-on-transfer would break share/asset math.
- First-depositor protection. OpenZeppelin v5 ERC‑4626’s virtual shares/assets already mitigate the donation/inflation attack. I additionally coded an optional
minInitialDeposit(initial minimum deposit); when set, the first deposit must be ≥ that amount. I assume the admin sets it appropriately for the asset’s decimals and liquidity, or leaves it 0.
- Underlying is a valid reserve. The vault’s
underlyingandpoolAddressesProvidermust correspond to an Aave v3 pool where that asset is a configured reserve. OtherwisegetReserveDatawould not return a valid aToken and initialization would revert. - Same chain and pool. The pool and addresses provider are assumed to be for the target chain (e.g. Ethereum mainnet). Using a different chain or pool would make supply/withdraw behave incorrectly.
- Aave withdraw semantics. I assume
IPool.withdraw(asset, amount, to)returns exactlyamountof underlying totowhen the vault has sufficient aToken balance. The contract reverts on mismatch (AaveWithdrawMismatch). Edge cases (e.g. Aave freeze, slashing, or rounding that returns less) are not handled beyond that revert. - No reserve freeze / slashing. The design does not explicitly handle Aave reserve freeze or slashing. If the reserve is frozen or balance is reduced by protocol logic, accounting may diverge until harvest/withdraw paths are revisited.
- Subscription and funding. The VRF subscription must exist, be funded (LINK or native as configured), and allow the coordinator to bill the configured key hash and gas limit. Unfunded or misconfigured subscriptions cause request or fulfillment to fail.
- Coordinator and chain. The coordinator address and key hash must match the deployment chain (e.g. mainnet vs testnet). Fulfillment is only sent on the same chain as the request.
- Winner selection timing. Eligibility is evaluated at finalization time (current share holders). I assume the holder list and balances are correct at that moment; no assumption is made about fairness of timing relative to VRF fulfillment delay.
- Role holders are trusted.
DEFAULT_ADMIN_ROLE,YIELD_MANAGER_ROLE, andDEPOSIT_MANAGER_ROLE(and router admin / raffle admin) are assumed to be honest or at least non-malicious for the intended use. A maliciousYIELD_MANAGER_ROLEcould harvest yield to an arbitrary address; a maliciousDEPOSIT_MANAGER_ROLEcould disable deposits. - Router and raffle. The router is trusted to route assets to the correct vault and (when
RAFFLE_CALLER_ROLEis used) to pay only the designated winner. The raffle manager is trusted to select the winner according to the documented rules (share-weighted, excludingexcludedRecipient). - Upgrades. UUPS upgradeability assumes that only a trusted admin deploys and upgrades to a sound implementation; a malicious implementation could break the vault.
| Interface | Purpose |
|---|---|
IHTokenVault |
Per-asset vault: ERC‑4626 surface plus claimableYield(), withdrawYield(to, amount), share(), and optional holder enumeration (holderCount(), holderAt(i)). Implemented by HTokenVault. |
IMultiAssetVaultRouter |
Full router API: vault(asset), deposit / mint / withdraw / redeem per asset, add / remove / enable assets, claimableYield(asset), withdrawYield(asset, to, amount), and withdrawYieldToWinner(asset, winner). Implemented by MultiAssetVaultRouter. |
IRouterRaffle |
Minimal router surface for the raffle: vault(asset), claimableYield(asset), withdrawYieldToWinner(asset, winner). Implemented by MultiAssetVaultRouter; used by YieldRaffleManager so it depends only on raffle-related router methods. |
IYieldRaffleManager |
Raffle manager API: requestYieldRaffle(asset), finalizeYieldRaffle(requestId), setExcludedRecipient, setVrfConfig, and view functions for request state. Implemented by YieldRaffleManager. |
Why two raffle-related interfaces? The raffle involves the router (which holds yield and pays the winner) and the raffle manager (which requests VRF, selects the winner, and instructs the router). IRouterRaffle is the minimal surface the router exposes for the raffle so the manager depends only on that; IYieldRaffleManager is the public API of the raffle (request/finalize/config) for admins and integrators.
There are other ways of implementing a multi-asset vault system, depending on how "unified approach" is interpreted. Some possible options are:
- A simple on-chain registry for vaults which may or may not be used with a helper lib for safe deposit / withdraw interactions. I didn't select this, preferring the router approach which (for me) provides clearer separation of concerns and user journey.
- Single contract with tokenId shares (using ERC‑1155 or some form of multi-token shares). Not selected because it deviates so much from protocol expectations (ERC-20).
- An aggregator vault. Not selected because having one ERC‑20 share token backed by multiple underlyings adds complexity.
- Diamond pattern with composable modules for vaults. Not selected because of implementation overhead and it doesn't necessarily provide one share token per asset more easily than other approaches.
This repo is tightly scoped for demo purposes. Before production use, the following could also be considered (not exhaustive):
- ERC-7575 (as discussed above)
- Factory vault generation, or beacon approach to setting up vaults, or clones (if non-upgradeable vaults). Given more time I would consider implementing factory vault generation or beacon. The beacon approach would provide an alternative to per-vault UUPS upgrades and enable single-action upgrades across all vaults, at the cost of increased contract management risk; potentially worth it if many different vaults / assets are foreseen
PAUSER_ROLE(emergency pause)FEE_SETTER_ROLE+ configurable admin fee on yield- domain separation / signature flows (EIP‑712)
- ERC‑2771 (trusted forwarder / meta-tx)
- upgradeable
MultiAssetVaultRouter - upgradeable
YieldRaffleManager
Create a .env file from the example and fill in values as needed.
cp .env.example .envforge buildUnit tests live under test/unit/. They use mocks; no mainnet fork or RPC needed. Fuzz and invariant tests run with forge test as well.
forge testFork tests live under test/integration/. Requires ETH_RPC_URL in .env (or exported). Runs unit tests plus integration tests that fork mainnet (Aave v3, etc.).
forge test --fork-url $ETH_RPC_URLRun only integration test files:
forge test --fork-url $ETH_RPC_URL --match-path "test/integration/*.t.sol"Run the fork tests that double as demonstrations (yield accrual, multiple users, logging):
forge test --fork-url $ETH_RPC_URL --match-test "testFork_.*demonstration" -vvGenerate a coverage report (unit tests; for fork tests use --fork-url $ETH_RPC_URL if you want them included):
forge coverageSet the required variables for the script in .env, then run with your RPC (and for live nets, --private-key or keystore). Foundry loads .env automatically.
HTokenVault (implementation + ERC1967 proxy):
forge script script/HTokenVault.s.sol:HTokenVaultScript --rpc-url $ETH_RPC_URL --broadcastMultiAssetVaultRouter (constructor: admin):
forge script script/MultiAssetVaultRouter.s.sol:MultiAssetVaultRouterScript --rpc-url $ETH_RPC_URL --broadcastYieldRaffleManager (constructor: router, VRF coordinator, subscription, key hash, gas limit, confirmations, native payment):
forge script script/YieldRaffleManager.s.sol:YieldRaffleManagerScript --rpc-url $ETH_RPC_URL --broadcastUse --verify to verify on Etherscan after deploy if configured.
- Foundry book: https://book.getfoundry.sh/


