Skip to content

Multi‑Asset ERC-4626 Vault Router with VRF Yield Raffle (Chainlink VRF v2.5)

Notifications You must be signed in to change notification settings

atkinsonholly/raffle-vault

Repository files navigation

Multi‑Asset ERC‑4626 Vault Router with Chainlink VRF Yield Raffle

codecov

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.


Summary

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 → assert claimableYield() > 0, harvest via withdrawYield).
  • 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.

Design overview

ERC‑4626 basis

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 exit
    • redeem(shares, receiver, owner), share‑denominated exit

This repo uses OpenZeppelin's ERC4626Upgradeable implementation.

List of standards that have been included

  • 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() returns address(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).


Intended flows

User flow: deposit and withdraw

Users can interact either:

  • directly with an HTokenVault, or
  • via the MultiAssetVaultRouter unified 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

User Journey

Admin flow: add / remove assets

Only admin can register new assets / vaults and enable or disable deposits for an asset.

Add and remove asset diagram

Add/Remove Asset

Admin flow: VRF raffle yield harvest

The Chainlink VRF v2.5 integration supports harvesting each vault's yield and sending it to a randomly selected eligible winner.

VRF overview diagram

VRF Overview


Roles and permissions

Vault (HTokenVault)

  • DEFAULT_ADMIN_ROLE
    • Authorizes UUPS upgrades
    • General admin authority
  • YIELD_MANAGER_ROLE
    • The only role allowed to harvest yield via withdrawYield(to, amount)
  • Deposit gating role
    • Deposits / mints can be enabled / disabled at the vault level so remove token cannot be bypassed by direct vault calls.
    • Implemented as DEPOSIT_MANAGER_ROLE (typically granted to the router).

Router (MultiAssetVaultRouter)

  • DEFAULT_ADMIN_ROLE
    • Can add/remove/enable/disable assets
    • Can manually call withdrawYield(asset, to, amount) (admin override)
  • RAFFLE_CALLER_ROLE (if enabled)
    • Allows YieldRaffleManager to instruct the router to pay harvested yield to a chosen winner

Raffle Manager (YieldRaffleManager)

  • 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

Deployment and configuration intentions

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).


How yield is kept separate from depositor principal

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.

Mechanism

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.

Key overridden functions / hooks

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.

First deposit and share price safety

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:

  1. OZ’s built-in protection, virtual shares / assets prevent donation / inflation abuse of the share price.
  2. Optional initial minimum, minInitialDeposit enforces 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, minInitialDeposit can be set to 0.

Multi‑asset support: Router approach

Description

This system supports multiple ERC‑20 assets via a unified interface:

  • MultiAssetVaultRouter exposes:
    • 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.

Why not ERC‑7575 (yet)

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:

  1. MultiAssetVaultRouter.vault(asset) gives an explicit on-chain mapping from an ERC‑20 asset to its vault instance;
  2. HTokenVault.share() makes it explicit what the share token is.

Chainlink VRF v2.5 integration

Chainlink VRF v2.5 is integrated to support a random yield distribution mechanism.

What happens

The flow is request → fulfill (store randomness) → finalize (select winner + router pays):

  1. Request — Admin (or RAFFLE_ADMIN_ROLE) calls requestYieldRaffle(asset); the manager requests randomness from VRF.
  2. Fulfill — VRF callback fulfillRandomWords(requestId, randomWords) stores the random word and marks the request fulfilled.
  3. Finalize — Anyone calls finalizeYieldRaffle(requestId); the manager selects a winner among current share holders (share-weighted, excluding excludedRecipient) and calls router.withdrawYieldToWinner(asset, winner); the router harvests claimableYield() 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.


Key differences vs Aave's ATokenVault.sol

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.

Assumptions

The design and yield logic rely on the following assumptions. Violations may break accounting or security.

Yield logic

  • 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. lastVaultBalance and accumulatedYield are updated on every deposit, withdrawal, and yield harvest. I assume no other code path changes the vault’s aToken balance; otherwise claimableYield and totalAssets would 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.

Protocol integration (Aave v3)

  • Underlying is a valid reserve. The vault’s underlying and poolAddressesProvider must correspond to an Aave v3 pool where that asset is a configured reserve. Otherwise getReserveData would 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 exactly amount of underlying to to when 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.

Protocol integration (Chainlink VRF v2.5)

  • 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.

Trust and roles

  • Role holders are trusted. DEFAULT_ADMIN_ROLE, YIELD_MANAGER_ROLE, and DEPOSIT_MANAGER_ROLE (and router admin / raffle admin) are assumed to be honest or at least non-malicious for the intended use. A malicious YIELD_MANAGER_ROLE could harvest yield to an arbitrary address; a malicious DEPOSIT_MANAGER_ROLE could disable deposits.
  • Router and raffle. The router is trusted to route assets to the correct vault and (when RAFFLE_CALLER_ROLE is used) to pay only the designated winner. The raffle manager is trusted to select the winner according to the documented rules (share-weighted, excluding excludedRecipient).
  • Upgrades. UUPS upgradeability assumes that only a trusted admin deploys and upgrades to a sound implementation; a malicious implementation could break the vault.

Interfaces

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.


Thoughts on alternative approaches

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.

Out of scope (for now)

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

Setup

Environment variables

Create a .env file from the example and fill in values as needed.

cp .env.example .env

Usage

Build

forge build

Test (unit only, no fork)

Unit 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 test

Fork / integration tests

Fork 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_URL

Run only integration test files:

forge test --fork-url $ETH_RPC_URL --match-path "test/integration/*.t.sol"

Integration demonstrations

Run the fork tests that double as demonstrations (yield accrual, multiple users, logging):

forge test --fork-url $ETH_RPC_URL --match-test "testFork_.*demonstration" -vv

Coverage

Generate a coverage report (unit tests; for fork tests use --fork-url $ETH_RPC_URL if you want them included):

forge coverage

Deploy

Set 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 --broadcast

MultiAssetVaultRouter (constructor: admin):

forge script script/MultiAssetVaultRouter.s.sol:MultiAssetVaultRouterScript --rpc-url $ETH_RPC_URL --broadcast

YieldRaffleManager (constructor: router, VRF coordinator, subscription, key hash, gas limit, confirmations, native payment):

forge script script/YieldRaffleManager.s.sol:YieldRaffleManagerScript --rpc-url $ETH_RPC_URL --broadcast

Use --verify to verify on Etherscan after deploy if configured.


Documentation

About

Multi‑Asset ERC-4626 Vault Router with VRF Yield Raffle (Chainlink VRF v2.5)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors