diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ed67ef --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# YieldSave Contracts + +Non-custodial USDC savings vault that automatically routes deposits into Aave V3 to earn yield. + +Users deposit USDC, receive vault shares, and withdraw principal plus net yield at any time. A protocol fee (default 5%) is taken only from yield — principal is mathematically protected. + +## Deployments + +| Network | Address | Explorer | +|---|---|---| +| Sepolia | `0x6C2Df464b38e92Ec8d01f8BEaF621f1ad894C107` | [Etherscan](https://sepolia.etherscan.io/address/0x6C2Df464b38e92Ec8d01f8BEaF621f1ad894C107) | +| Base Sepolia | `0xC0aAd48188dabF8d5B33e30A0946d79d5C8F6323` | [Blockscout](https://base-sepolia.blockscout.com/address/0xC0aAd48188dabF8d5B33e30A0946d79d5C8F6323) | + +## Quick Start + +**Prerequisites:** [Foundry](https://book.getfoundry.sh/getting-started/installation) ≥ 0.2 + +```bash +git clone https://github.com/your-org/ys-contracts +cd ys-contracts +forge install +forge build +forge test +``` + +All unit and scenario tests run against mock contracts with no RPC required. See the [Developer Guide](docs/developer-guide.md) for fork tests and environment setup. + +## Documentation + +| Document | Purpose | +|---|---| +| [Technical Specification](SPEC.md) | Complete canonical reference: architecture, storage layout, security model, risks | +| [Contract Reference](docs/contracts.md) | Per-contract: purpose, state, functions, logic flow, events, reverts, interaction diagrams | +| [Developer Guide](docs/developer-guide.md) | Orientation, common workflows, code conventions | +| [Setup](docs/setup.md) | Prerequisites, installation, environment variables, Anvil workflow | +| [Architecture](docs/architecture.md) | Contract design, share model, fee math, invariants, security model | +| [Testing](docs/testing.md) | Test suite structure, running tests, writing new tests | +| [Deployment](docs/deployment.md) | Deploying to each network, verification, post-deploy checklist | +| [Reference](docs/reference.md) | Full ABI, function signatures, events, errors, `cast` one-liners | +| [Troubleshooting](docs/troubleshooting.md) | Common errors and how to fix them | +| [FAQ](docs/faq.md) | Frequently asked developer questions | +| [Maintenance](docs/maintenance.md) | Monitoring, fee collection, incident response, re-deployment | +| [User Guide](guide.md) | End-user product documentation | +| [Contributing](CONTRIBUTING.md) | Contribution workflow and standards | + +## Tech Stack + +| Layer | Technology | +|---|---| +| Smart contracts | Solidity 0.8.30 | +| Toolchain | Foundry (forge, cast, anvil) | +| Access control utility | OpenZeppelin `ReentrancyGuard` | +| Yield source | Aave V3 (USDC → aUSDC) | +| Testing | Forge unit tests, scenario tests, mock contracts, fork tests | + +## Repository Layout + +``` +src/ + YieldSaveVault.sol Main vault contract + interfaces/ + IERC20.sol Minimal ERC-20 interface + IPool.sol Aave V3 Pool interface + +test/ + YieldSaveVault.t.sol Core unit + fork tests + helpers/ Shared fixtures and fork utilities + scenarios/ Focused scenario tests (deposit, withdraw, fee, share math) + mocks/ MockERC20, MockAavePool + fork/ Real-chain integration tests (Base Sepolia) + +script/ + Deploy.s.sol Deployment script (all networks) + VerifyAddresses.s.sol Address sanity-check utility + +deployments/ JSON records written at deploy time +docs/ Developer documentation +``` + +## License + +MIT diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..23be9a6 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,1150 @@ +# YieldSave Technical Specification + +**Version:** 1.0 +**Contract:** `YieldSaveVault` +**Solidity:** 0.8.30 +**Last updated:** 2026-05-03 + +This document is the single authoritative technical reference for the YieldSave protocol. It is intended for auditors, integration engineers, and engineers joining the project. It supersedes scattered information in other docs for the topics it covers. + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Blockchain Network](#2-blockchain-network) +3. [Token Standards](#3-token-standards) +4. [Technology Stack](#4-technology-stack) +5. [Repository Structure](#5-repository-structure) +6. [Contract Architecture](#6-contract-architecture) +7. [Detailed Contract Reference](#7-detailed-contract-reference) +8. [Storage Layout](#8-storage-layout) +9. [Security & Access Control Model](#9-security--access-control-model) +10. [Event Documentation](#10-event-documentation) +11. [Deployment Guide](#11-deployment-guide) +12. [Testing Guide](#12-testing-guide) +13. [Known Risks & Assumptions](#13-known-risks--assumptions) + +--- + +## 1. Project Overview + +YieldSave is a non-custodial savings protocol that allows any wallet holder to earn yield on USDC without taking custody or requiring any off-chain infrastructure. Users deposit USDC into a single smart contract vault. The vault supplies that USDC to Aave V3, which pays a variable interest rate in the form of rebasing aUSDC tokens. Users earn yield proportional to their share of the vault. When they withdraw, they receive their original principal plus net yield, with a protocol fee deducted only from the yield portion. + +### Core guarantees + +1. **Principal protection.** The protocol fee is mathematically bounded to the yield portion. A user who earns zero yield pays zero fee. A user's principal is always returned in full. +2. **Non-custodial.** No address — including the deployer — can withdraw user funds. Users can recover funds at any time by calling `withdraw` directly on-chain. +3. **Immutable parameters.** All protocol configuration (fee rate, treasury, token addresses) is set at construction and cannot be changed. +4. **Permissionless.** Any wallet with USDC can deposit or withdraw. No KYC, whitelist, or minimum balance. + +### What YieldSave is not + +- It is not a lending protocol. +- It is not a yield aggregator (no strategy selection; Aave V3 is the sole yield source). +- It is not an ERC-4626 vault (shares are not transferable ERC-20 tokens in this version). +- It is not upgradeable. + +--- + +## 2. Blockchain Network + +### EVM compatibility + +YieldSave is deployed on EVM-compatible networks. It requires Aave V3 to be available on the target network, as the vault calls `IPool.supply` and `IPool.withdraw` directly. No cross-chain messaging or bridge infrastructure is used. + +### Deployed networks + +| Network | Chain ID | Type | Vault Address | Block | +|---|---|---|---|---| +| Ethereum Sepolia | 11155111 | Testnet | `0x6C2Df464b38e92Ec8d01f8BEaF621f1ad894C107` | 10,759,020 | +| Base Sepolia | 84532 | Testnet | `0xC0aAd48188dabF8d5B33e30A0946d79d5C8F6323` | 40,872,728 | +| Base Mainnet | 8453 | Mainnet | Not yet deployed | — | + +### Aave V3 addresses (Base Sepolia) + +These are the canonical addresses used by the Base Sepolia deployment and the fork test suite. + +| Contract | Address | +|---|---| +| USDC | `0xba50Cd2A20f6DA35D788639E581bca8d0B5d4D5f` | +| aUSDC | `0x10F1A9D11CDf50041f3f8cB7191CBE2f31750ACC` | +| Aave V3 Pool | `0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27` | + +### Network selection in deployment script + +The deployment script (`script/Deploy.s.sol`) detects the target network from `block.chainid` at runtime and loads the corresponding Aave addresses from environment variables. Adding support for a new network requires adding a `chainId` branch to `_loadNetworkConfig`. + +--- + +## 3. Token Standards + +### 3.1 USDC — Deposit and withdrawal token + +| Property | Value | +|---|---| +| Standard | ERC-20 | +| Issuer | Circle Internet Financial | +| Decimals | 6 | +| Symbol | USDC | +| Behaviour | Standard ERC-20; Circle retains blacklist authority | + +USDC is the only accepted deposit asset. The vault has no logic for any other token. All `amount` parameters are denominated in USDC units (6 decimal places): `1 USDC = 1_000_000`. + +**Non-standard behaviour to be aware of:** Some older USDC deployments do not return a `bool` from `transfer` and `approve`. The vault uses a low-level call pattern (`_safeTransfer`, `_safeTransferFrom`, `_forceApprove`) that handles both conforming and non-conforming ERC-20 implementations. + +### 3.2 aUSDC — Yield-bearing wrapper (Aave) + +| Property | Value | +|---|---| +| Standard | ERC-20 (Aave AToken) | +| Issuer | Aave V3 Protocol | +| Decimals | 6 (matches underlying USDC) | +| Symbol | aUSDC | +| Behaviour | Rebasing — balance grows every block to reflect accrued interest | + +The vault receives aUSDC when it calls `aavePool.supply`. The vault does not interact with aUSDC directly; it reads `aUsdc.balanceOf(address(this))` to determine total assets and calls `aavePool.withdraw` to redeem aUSDC back to USDC. The rebasing nature of aUSDC is how yield is distributed: as the aUSDC balance of the vault grows without any minting event, the share price increases. + +### 3.3 Vault shares — Internal accounting unit + +| Property | Value | +|---|---| +| Standard | None — internal mapping, not transferable | +| Decimals | 6 (matches USDC on first deposit; preserved thereafter) | +| Symbol | None | +| Behaviour | Non-transferable; tracked in `userShares[address]` mapping | + +Vault shares are not ERC-20 tokens. They cannot be transferred, traded, or used as collateral in other protocols. This is a deliberate design choice for the MVP to reduce audit surface and eliminate composability risks. ERC-4626 share tokens are planned for a future version. + +**Relationship to ERC-4626:** The vault adopts ERC-4626 naming conventions (`deposit`, `withdraw`, `previewDeposit`, `previewWithdraw`) and the proportional share model, but does not implement the full ERC-4626 interface. Specifically: no `ERC20` methods on shares, no `mint`/`redeem` entry points, no `asset()` function, no `totalAssets()` public function, no `convertToShares`/`convertToAssets` functions. + +--- + +## 4. Technology Stack + +### Solidity + +| Property | Value | +|---|---| +| Version | 0.8.30 (pinned via `foundry.toml: solc = "0.8.30"`) | +| Pragma | `^0.8.30` | +| Optimiser | Enabled, 200 runs | +| ABI coder | Default (v2) | + +Solidity 0.8.x provides built-in overflow/underflow protection (checked arithmetic by default). Custom errors (introduced in 0.8.4) are used throughout for gas-efficient reverts. + +### Framework + +| Tool | Version | Purpose | +|---|---|---| +| Foundry (forge) | latest stable | Compile, test, script, deploy | +| Foundry (cast) | latest stable | On-chain reads, transaction inspection | +| Foundry (anvil) | latest stable | Local EVM node | + +### Dependencies + +| Library | Version | Import path | Usage | +|---|---|---|---| +| OpenZeppelin Contracts | v5.6.1 | `openzeppelin-contracts/` | `ReentrancyGuard` | +| forge-std | v1.16.0 | `forge-std/` | Test base contracts, cheatcodes, `console2` | + +No other dependencies. In particular: no Hardhat, no Node.js, no Truffle, no OpenZeppelin upgrades, no Chainlink, no OpenZeppelin SafeERC20 (the vault uses its own low-level wrappers). + +--- + +## 5. Repository Structure + +``` +ys-contracts/ +│ +├── src/ Smart contract source +│ ├── YieldSaveVault.sol Main vault contract (180 lines) +│ └── interfaces/ +│ ├── IERC20.sol Minimal ERC-20 interface +│ └── IPool.sol Aave V3 Pool interface +│ +├── test/ Foundry test suite +│ ├── YieldSaveVault.t.sol Core unit tests (12 tests) +│ ├── helpers/ +│ │ ├── AaveFork.sol Base class: deploys MockERC20 + MockAavePool +│ │ ├── Fixtures.sol Base class: deploys vault, pre-funds users +│ │ └── BaseSepoliaFork.sol Base class: forks Base Sepolia, wires real Aave +│ ├── scenarios/ +│ │ ├── Deposit.t.sol Deposit flows (4 tests) +│ │ ├── Withdraw.t.sol Withdrawal flows (4 tests) +│ │ ├── Fee.t.sol Fee mechanics (3 tests) +│ │ └── ShareMath.t.sol Share price math (3 tests) +│ ├── mocks/ +│ │ ├── MockERC20.sol Minimal ERC-20 with mint/burn +│ │ └── MockAavePool.sol Deterministic mock: supply, withdraw, accrueYield +│ └── fork/ +│ └── BaseSepoliaIntegration.t.sol Real Aave V3 integration tests (3 tests) +│ +├── script/ +│ ├── Deploy.s.sol Deployment script (all networks) +│ └── VerifyAddresses.s.sol Address sanity check utility +│ +├── deployments/ +│ ├── sepolia.json Deployment record: Sepolia +│ ├── base-sepolia.json Deployment record: Base Sepolia +│ └── base.json Deployment record: Base Mainnet (empty) +│ +├── docs/ Developer documentation +│ ├── architecture.md +│ ├── deployment.md +│ ├── developer-guide.md +│ ├── faq.md +│ ├── maintenance.md +│ ├── reference.md +│ ├── setup.md +│ ├── testing.md +│ └── troubleshooting.md +│ +├── lib/ Git submodule dependencies +│ ├── forge-std/ Foundry standard library (v1.16.0) +│ └── openzeppelin-contracts/ OpenZeppelin (v5.6.1) +│ +├── SPEC.md This document +├── README.md Project overview and quick start +├── CONTRIBUTING.md Contribution guidelines +├── guide.md End-user product documentation +├── foundry.toml Foundry configuration +├── foundry.lock Dependency version lock +├── remappings.txt Solidity import path aliases +├── Makefile Build and deployment automation +└── .env.example Environment variable template +``` + +### Key file responsibilities + +| File | Responsibility | +|---|---| +| `src/YieldSaveVault.sol` | All protocol logic: deposits, withdrawals, share accounting, fee calculation | +| `src/interfaces/IPool.sol` | Aave V3 interface — only `supply` and `withdraw` are needed | +| `src/interfaces/IERC20.sol` | Minimal ERC-20 — only the 4 functions the vault calls | +| `script/Deploy.s.sol` | Network-aware deployment: detects chain, loads addresses, writes record | +| `deployments/*.json` | Authoritative on-chain addresses per network — read by frontends | +| `test/helpers/Fixtures.sol` | Single source of truth for test setup shared across all scenario tests | + +--- + +## 6. Contract Architecture + +### 6.1 System overview + +``` + ┌──────────────────────────────────────────┐ + │ User Wallet │ + │ │ + │ 1. approve(vault, amount) │ + │ 2. deposit(amount) ─────────────────► │ + │ ◄───────────── withdraw(shares) 3. │ + └──────────────────────────────────────────┘ + │ deposit / withdraw + ┌────────────▼─────────────────────────────┐ + │ YieldSaveVault │ + │ │ + │ State: │ + │ totalShares uint256 │ + │ userShares[addr] mapping │ + │ userDeposits[addr] mapping │ + │ │ + │ Immutables: │ + │ usdc, aUsdc, aavePool, treasury, │ + │ feeRate │ + └────────────┬─────────────────────────────┘ + │ supply / withdraw + ┌────────────▼─────────────────────────────┐ + │ Aave V3 Pool │ + │ │ + │ USDC ──supply──► aUSDC (held by vault) │ + │ aUSDC ─withdraw─► USDC (returned) │ + │ aUSDC balance grows every block │ + └──────────────────────────────────────────┘ +``` + +**Token flow on deposit:** +1. User approves vault to spend USDC +2. Vault calls `usdc.transferFrom(user, vault, amount)` +3. Vault approves Aave Pool to spend USDC +4. Vault calls `aavePool.supply(usdc, amount, vault, 0)` — aUSDC minted to vault +5. Vault mints shares to user, records principal + +**Token flow on withdrawal:** +1. Vault calculates gross USDC owed, principal portion, yield, and fee +2. Vault burns user shares, reduces principal record +3. Vault calls `aavePool.withdraw(usdc, grossAssets, vault)` — USDC returned to vault +4. Vault calls `usdc.transfer(user, payout)` — net payout to user +5. Vault calls `usdc.transfer(treasury, fee)` — fee to treasury (if non-zero) + +### 6.2 Share model + +Shares represent proportional ownership of the vault's total USDC-denominated assets. `totalAssets` is the live aUSDC balance of the vault, which increases every block as Aave accrues interest. + +**First deposit (bootstrapping):** +``` +shares = amount +``` +This sets the initial share price to exactly 1.0 USDC per share. + +**Subsequent deposits:** +``` +shares = amount × totalShares / totalAssets +``` + +Because `totalAssets` grows (via Aave yield) while `totalShares` does not, subsequent depositors receive fewer shares per USDC. Existing shareholders' proportional claim on `totalAssets` remains unchanged — their shares are worth more USDC. + +**User's USDC claim at any point:** +``` +claim = userShares[user] × totalAssets / totalShares +``` + +**Share price (implicit):** +``` +sharePrice = totalAssets / totalShares +``` + +Share price is monotonically non-decreasing in normal operation. It increases as Aave accrues yield and is unchanged by deposits or withdrawals (because both change `totalShares` and `totalAssets` in the same proportion). + +### 6.3 Fee model + +The protocol fee is applied at withdrawal time and only to the yield portion of the withdrawal. Principal is always returned in full. + +**Step 1 — Gross assets for the redeemed shares:** +``` +grossAssets = shares × totalAssets / totalShares +``` + +**Step 2 — Principal portion attributable to these shares:** +``` +principalPortion = userDeposits[user] × shares / userShares[user] +``` + +This proportional reduction means partial withdrawals correctly track which fraction of the principal is being redeemed. + +**Step 3 — Yield (clamped to zero):** +``` +yield = max(0, grossAssets − principalPortion) +``` + +The clamp ensures that rounding errors or edge cases where `grossAssets < principalPortion` never result in a negative fee or a fee charged against principal. + +**Step 4 — Fee:** +``` +fee = yield × feeRate / 10_000 +``` + +**Step 5 — Payout:** +``` +payout = grossAssets − fee +``` + +**Worked example** (from `test_FullWithdrawalReturnsPrincipalPlusNetYield`): +- Alice deposits 100 USDC → 100,000,000 shares (1:1) +- Vault earns 10 USDC yield → totalAssets = 110,000,000 +- Alice redeems all 100,000,000 shares +- grossAssets = 100,000,000 × 110,000,000 / 100,000,000 = 110,000,000 +- principalPortion = 100,000,000 × 100,000,000 / 100,000,000 = 100,000,000 +- yield = 110,000,000 − 100,000,000 = 10,000,000 +- fee = 10,000,000 × 500 / 10,000 = 500,000 +- payout = 110,000,000 − 500,000 = 109,500,000 (109.5 USDC) +- treasury receives 500,000 (0.5 USDC) + +### 6.4 Partial withdrawal and principal tracking + +When a user makes a partial withdrawal, `userDeposits[user]` is reduced proportionally: +``` +userDeposits[user] -= principalPortion +``` +where `principalPortion = userDeposits[user] × shares / userShares[user]` at the time of withdrawal. + +This means the per-share principal cost basis is preserved across multiple partial withdrawals. A user who withdraws 50% of their shares retains exactly 50% of their recorded principal for future withdrawals. + +### 6.5 Design decisions + +**Why not implement ERC-4626 fully?** +ERC-4626 requires shares to implement the full ERC-20 interface (including `transfer`, `approve`, etc.). Adding that requires significant additional code paths, increases the reentrancy attack surface, and adds composability vectors that are inappropriate for an MVP with no audit. Future versions can add ERC-4626 by making shares a separate ERC-20 contract or by adding the token interface to this contract. + +**Why are all parameters immutable?** +Mutable governance parameters (even behind a timelock) require trusting the governance mechanism. For an MVP, immutability provides a stronger and simpler trust guarantee. The deployer cannot extract fees beyond the declared rate or redirect fees to a different address after deployment. Changes require a new contract deployment and user migration. + +**Why is there no admin pause function?** +Pause mechanisms require trusting the pause key holder. An emergency pause by a compromised key is itself an attack vector. The vault defers to Aave's own pool-level pause mechanism for Aave-specific emergencies. + +**Why separate `usdc` and `aUsdc` interfaces?** +Both are ERC-20 tokens but serve different roles. Separating them in the constructor makes the intent explicit and allows the correct address to be verified independently. Using a single token variable would obscure which token is being operated on in each call. + +--- + +## 7. Detailed Contract Reference + +### Contract: YieldSaveVault + +``` +File: src/YieldSaveVault.sol +Inherits: ReentrancyGuard (OpenZeppelin v5.6.1) +License: MIT +``` + +--- + +### Constructor + +```solidity +constructor( + address usdc_, + address aUsdc_, + address aavePool_, + address treasury_, + uint256 feeRate_ +) +``` + +Sets all immutable state variables. Reverts if any address is `address(0)` or if `feeRate_` exceeds `MAX_FEE_BPS`. + +| Parameter | Validation | Effect | +|---|---|---| +| `usdc_` | `!= address(0)` | Stored as `usdc` | +| `aUsdc_` | `!= address(0)` | Stored as `aUsdc` | +| `aavePool_` | `!= address(0)` | Stored as `aavePool` | +| `treasury_` | `!= address(0)` | Stored as `treasury` | +| `feeRate_` | `<= MAX_FEE_BPS (1000)` | Stored as `feeRate` | + +--- + +### Constants + +```solidity +uint256 public constant BPS_DENOMINATOR = 10_000; +uint256 public constant MAX_FEE_BPS = 1_000; +``` + +Constants are not stored in contract storage. They are inlined as literals by the compiler. + +--- + +### Immutable variables + +```solidity +IERC20 public immutable usdc; +IERC20 public immutable aUsdc; +IPool public immutable aavePool; +address public immutable treasury; +uint256 public immutable feeRate; +``` + +Immutables are embedded in the contract's deployed bytecode during construction. They cannot be read from storage — they are loaded directly by the bytecode at execution time. This is more gas-efficient than storage reads. + +--- + +### Public state variables + +```solidity +uint256 public totalShares; +mapping(address => uint256) public userShares; +mapping(address => uint256) public userDeposits; +``` + +| Variable | Type | Unit | Description | +|---|---|---|---| +| `totalShares` | `uint256` | shares (6 dp) | Sum of all outstanding shares across all users | +| `userShares` | `mapping` | shares (6 dp) | Per-user share balance | +| `userDeposits` | `mapping` | USDC (6 dp) | Per-user cumulative principal contributed, adjusted for partial withdrawals | + +`totalAssets` is not a stored variable. It is computed on every access as `aUsdc.balanceOf(address(this))`. + +--- + +### External write functions + +#### `deposit(uint256 amount) external nonReentrant returns (uint256 shares)` + +Transfers `amount` USDC from `msg.sender` to the vault, supplies it to Aave, and mints `shares` to `msg.sender`. + +**Pre-conditions:** +- Caller has approved vault to spend at least `amount` USDC +- `amount > 0` +- `_previewDeposit(amount, currentTotalAssets) > 0` + +**Execution steps:** +1. Revert with `ZeroAmount` if `amount == 0` +2. Call `_previewDeposit` to calculate shares to mint +3. Revert with `ZeroSharesMinted` if `shares == 0` +4. `_safeTransferFrom(usdc, msg.sender, address(this), amount)` +5. `_forceApprove(usdc, address(aavePool), amount)` +6. `aavePool.supply(address(usdc), amount, address(this), 0)` +7. `userShares[msg.sender] += shares` +8. `userDeposits[msg.sender] += amount` +9. `totalShares += shares` +10. Emit `Deposited(msg.sender, amount, shares)` +11. Return `shares` + +**Post-conditions:** +- `aUsdc.balanceOf(address(this))` increased by `amount` (plus any yield accrued in the same block) +- `userShares[msg.sender]` increased by `shares` +- `userDeposits[msg.sender]` increased by `amount` +- `totalShares` increased by `shares` + +**Revert conditions:** + +| Error | Condition | +|---|---| +| `ZeroAmount` | `amount == 0` | +| `ZeroSharesMinted` | computed `shares == 0` (dust amount with very high share price) | +| `ERC20CallFailed` | `usdc.transferFrom` or `usdc.approve` returns false | + +--- + +#### `withdraw(uint256 shares) external nonReentrant returns (uint256 payout)` + +Redeems `shares` from `msg.sender`, withdraws the corresponding USDC from Aave, deducts the protocol fee, and transfers payout to `msg.sender` and fee to `treasury`. + +**Pre-conditions:** +- `shares > 0` +- `userShares[msg.sender] >= shares` + +**Execution steps:** +1. Revert with `ZeroAmount` if `shares == 0` +2. Load `userShareBalance = userShares[msg.sender]` +3. Revert with `InsufficientShares` if `shares > userShareBalance` +4. Call `_quoteWithdraw` to calculate `grossAssets`, `principalPortion`, `fee` +5. Compute `payout = grossAssets - fee` +6. `userShares[msg.sender] = userShareBalance - shares` +7. `userDeposits[msg.sender] -= principalPortion` +8. `totalShares -= shares` +9. `aavePool.withdraw(address(usdc), grossAssets, address(this))` +10. `_safeTransfer(usdc, msg.sender, payout)` +11. If `fee != 0`: `_safeTransfer(usdc, treasury, fee)` +12. Emit `Withdrawn(msg.sender, shares, grossAssets, fee, payout)` +13. Return `payout` + +**Post-conditions:** +- `aUsdc.balanceOf(address(this))` decreased by `grossAssets` +- `userShares[msg.sender]` decreased by `shares` +- `userDeposits[msg.sender]` decreased by `principalPortion` +- `totalShares` decreased by `shares` +- `msg.sender` USDC balance increased by `payout` +- `treasury` USDC balance increased by `fee` (if `fee > 0`) + +**Revert conditions:** + +| Error | Condition | +|---|---| +| `ZeroAmount` | `shares == 0` | +| `InsufficientShares` | `shares > userShares[msg.sender]` | +| `ERC20CallFailed` | any `usdc.transfer` call returns false | + +**Note on state update ordering:** State (`userShares`, `userDeposits`, `totalShares`) is updated before the external Aave and ERC-20 calls (steps 6–8 before step 9). This follows the checks-effects-interactions pattern and is reinforced by `nonReentrant`. + +--- + +### External view functions + +#### `getVaultBalance() external view returns (uint256)` + +Returns `aUsdc.balanceOf(address(this))`. This is the total USDC-denominated value managed by the vault, including all user deposits and all accrued yield. + +#### `getUserBalance(address user) external view returns (uint256)` + +Returns the net USDC payout `user` would receive if they withdrew all their shares right now (after the protocol fee on yield). Returns `0` if `userShares[user] == 0`. + +Internally calls `_previewWithdrawForUser(user, userShares[user])` and returns `payout`. + +#### `previewDeposit(uint256 amount) external view returns (uint256)` + +Returns the number of shares that would be minted for a deposit of `amount` at the current share price. Does not modify state. Returns `amount` when the vault is empty (first-deposit 1:1 ratio). Returns `0` when `amount == 0`. + +#### `previewWithdraw(uint256 shares) external view returns (uint256)` + +Returns the net payout `msg.sender` would receive for redeeming `shares`. Returns `0` if `shares == 0`, `userShares[msg.sender] == 0`, or `shares > userShares[msg.sender]`. + +#### `previewWithdrawFor(address user, uint256 shares) external view returns (uint256 payout, uint256 grossAssets, uint256 fee)` + +Full withdrawal preview for any `user` and `shares`. Returns all three components: +- `payout` — net USDC sent to user +- `grossAssets` — USDC value of `shares` before fee +- `fee` — protocol fee amount + +Returns `(0, 0, 0)` if `shares == 0`, `userShares[user] == 0`, or `shares > userShares[user]`. + +--- + +### Internal functions + +#### `_previewDeposit(uint256 amount, uint256 assetsBefore) internal view returns (uint256)` + +``` +if amount == 0: return 0 +if totalShares == 0 or assetsBefore == 0: return amount (first deposit, 1:1) +else: return amount * totalShares / assetsBefore +``` + +`assetsBefore` is passed in (rather than reading `_totalAssets()` again) so that `deposit` can snapshot the balance before the USDC transfer and use that snapshot for share calculation. + +#### `_quoteWithdraw(address user, uint256 shares, uint256 assets, uint256 currentTotalShares, uint256 userShareBalance) internal view returns (uint256 grossAssets, uint256 principalPortion, uint256 fee)` + +``` +grossAssets = shares * assets / currentTotalShares +principalPortion = userDeposits[user] * shares / userShareBalance +yield = grossAssets > principalPortion ? grossAssets - principalPortion : 0 +fee = yield * feeRate / BPS_DENOMINATOR +``` + +#### `_previewWithdrawForUser(address user, uint256 shares) internal view returns (uint256 payout, uint256 grossAssets, uint256 fee)` + +Guard-only wrapper around `_quoteWithdraw`. Returns `(0, 0, 0)` if inputs are invalid; otherwise calls `_quoteWithdraw` and computes `payout = grossAssets - fee`. + +#### `_totalAssets() internal view returns (uint256)` + +Returns `aUsdc.balanceOf(address(this))`. Called on every view and write operation that needs the current vault balance. Never cached. + +#### `_safeTransfer(IERC20 token, address to, uint256 amount) internal` + +Low-level ERC-20 transfer with return value check. Uses `address(token).call(abi.encodeCall(IERC20.transfer, (to, amount)))`. Reverts with `ERC20CallFailed` if the call fails or returns `false`. Handles tokens that return no data (treats empty return as success, as is conventional). + +#### `_safeTransferFrom(IERC20 token, address from, address to, uint256 amount) internal` + +Same pattern as `_safeTransfer` but for `transferFrom`. + +#### `_forceApprove(IERC20 token, address spender, uint256 amount) internal` + +Resets allowance to `0` first, then approves `amount`. Both calls use the low-level pattern and revert on failure. The double-step handles ERC-20 implementations that revert if `approve` is called when the existing allowance is non-zero (some older or non-standard tokens). This is a safe, explicit pattern rather than relying on OpenZeppelin's `SafeERC20`. + +--- + +### Custom errors + +```solidity +error ZeroAddress(); +error ZeroAmount(); +error InvalidFeeRate(); +error InsufficientShares(); +error ZeroSharesMinted(); +error ERC20CallFailed(); +``` + +| Error | 4-byte selector | Thrown by | Condition | +|---|---|---|---| +| `ZeroAddress` | `0xd92e233d` | constructor | Any address parameter is `address(0)` | +| `ZeroAmount` | `0x1f2a2005` | `deposit`, `withdraw` | `amount == 0` or `shares == 0` | +| `InvalidFeeRate` | — | constructor | `feeRate_ > MAX_FEE_BPS` | +| `InsufficientShares` | — | `withdraw` | `shares > userShares[msg.sender]` | +| `ZeroSharesMinted` | — | `deposit` | Share calculation rounds to 0 | +| `ERC20CallFailed` | — | `_safeTransfer`, `_safeTransferFrom`, `_forceApprove` | ERC-20 call fails or returns `false` | + +Custom errors are more gas-efficient than `require` strings because their ABI encoding is 4 bytes (selector only) rather than a variable-length string. + +--- + +### Interfaces + +#### `src/interfaces/IERC20.sol` + +Minimal subset of ERC-20. Only the four functions the vault calls are declared: + +```solidity +interface IERC20 { + function transfer(address to, uint256 value) external returns (bool); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + function totalSupply() external view returns (uint256); +} +``` + +Not used for direct calls — all ERC-20 calls go through the low-level `_safe*` wrappers. The interface is used only for type declarations. + +#### `src/interfaces/IPool.sol` + +Minimal Aave V3 Pool interface. Only `supply` and `withdraw` are declared: + +```solidity +interface IPool { + function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; + function withdraw(address asset, uint256 amount, address to) external returns (uint256); +} +``` + +--- + +## 8. Storage Layout + +Storage slots are assigned sequentially starting from slot 0. Inherited contracts occupy the lowest slots. + +### Inherited storage: ReentrancyGuard (OpenZeppelin v5.6.1) + +``` +Slot 0: _status (uint256) +``` + +`_status` holds the reentrancy sentinel. Its values in OZ v5 are: +- `1` (NOT_ENTERED): no reentrant call active +- `2` (ENTERED): a `nonReentrant` function is currently executing + +The `nonReentrant` modifier sets `_status = 2` on entry and resets it to `1` on exit. Any reentrant call that attempts to enter another `nonReentrant` function reverts immediately. + +### YieldSaveVault own storage + +``` +Slot 1: totalShares (uint256) +Slot 2: userShares (mapping(address => uint256)) +Slot 3: userDeposits (mapping(address => uint256)) +``` + +### Computing mapping slot keys + +For a mapping at slot `n`, the value for key `k` is stored at: +``` +keccak256(abi.encode(k, n)) +``` + +| Mapping | Slot | Key type | Value slot formula | +|---|---|---|---| +| `userShares[addr]` | 2 | `address` | `keccak256(abi.encode(addr, 2))` | +| `userDeposits[addr]` | 3 | `address` | `keccak256(abi.encode(addr, 3))` | + +### What is NOT in storage + +| Variable | Where it lives | +|---|---| +| `usdc`, `aUsdc`, `aavePool`, `treasury`, `feeRate` | Immutables — embedded in deployed bytecode | +| `BPS_DENOMINATOR`, `MAX_FEE_BPS` | Constants — inlined as literals by compiler | +| `totalAssets` | Not stored — computed on every access | + +### Full layout table + +``` +Slot Type Name Source +──── ───────────────────────────── ────────────────── ────────────────── +0 uint256 _status ReentrancyGuard +1 uint256 totalShares YieldSaveVault +2 mapping(address => uint256) userShares YieldSaveVault +3 mapping(address => uint256) userDeposits YieldSaveVault +``` + +### Storage inspection with cast + +```bash +VAULT=0xC0aAd48188dabF8d5B33e30A0946d79d5C8F6323 +RPC= + +# _status (slot 0) — expect 1 (NOT_ENTERED) when idle +cast storage $VAULT 0 --rpc-url $RPC + +# totalShares (slot 1) +cast storage $VAULT 1 --rpc-url $RPC + +# userShares[addr] — compute key first +USER=0xYourAddress +SLOT=$(cast keccak $(cast abi-encode "f(address,uint256)" $USER 2)) +cast storage $VAULT $SLOT --rpc-url $RPC + +# userDeposits[addr] +SLOT=$(cast keccak $(cast abi-encode "f(address,uint256)" $USER 3)) +cast storage $VAULT $SLOT --rpc-url $RPC +``` + +--- + +## 9. Security & Access Control Model + +### 9.1 Privilege model + +`YieldSaveVault` has **no privileged roles**. There is no `owner`, no `admin`, no `pauser`, no `upgrader`, and no `governance` address. The deployer receives no special access after deployment. The contract has no `Ownable`, `AccessControl`, or similar pattern. + +The `treasury` address is the only address that receives any benefit from the protocol (fees), but it has no ability to call any function or modify any state. It is a pure recipient. + +| Role | Exists | Address | Capabilities | +|---|---|---|---| +| Owner / Admin | No | — | — | +| Pauser | No | — | — | +| Fee recipient (treasury) | Yes | Set at construction | Receive USDC fees on each withdrawal; no contract functions | +| Upgrader | No | — | — | +| Any user | Yes | Any EOA or contract | Call `deposit` and `withdraw` with their own assets | + +### 9.2 Reentrancy protection + +Both `deposit` and `withdraw` are marked `nonReentrant` (OpenZeppelin v5 `ReentrancyGuard`). This prevents: + +- A malicious ERC-20 token calling back into the vault during `transfer`/`transferFrom`/`approve` +- A malicious Aave pool calling back into the vault during `supply`/`withdraw` + +The vault uses checks-effects-interactions ordering in `withdraw`: all state is updated (shares burned, principal reduced, `totalShares` decremented) before any external call (`aavePool.withdraw`, `usdc.transfer`). This means even if `nonReentrant` were removed, the state would be consistent before any external interaction. + +In `deposit`, state is updated after the USDC transfer from the user but after the Aave supply. This is acceptable because `_previewDeposit` uses the pre-transfer snapshot of `totalAssets` (preventing share inflation via sandwich attack on the `totalAssets` read). + +### 9.3 Input validation + +| Check | Location | Error | +|---|---|---| +| All constructor addresses non-zero | `constructor` | `ZeroAddress` | +| Fee rate ≤ 10% | `constructor` | `InvalidFeeRate` | +| Deposit amount > 0 | `deposit` | `ZeroAmount` | +| Deposit mints > 0 shares | `deposit` | `ZeroSharesMinted` | +| Withdraw shares > 0 | `withdraw` | `ZeroAmount` | +| Withdraw shares ≤ user balance | `withdraw` | `InsufficientShares` | + +### 9.4 Safe ERC-20 pattern + +The vault does not use OpenZeppelin `SafeERC20`. Instead it implements three private low-level wrappers: + +- `_safeTransfer` — wraps `transfer` +- `_safeTransferFrom` — wraps `transferFrom` +- `_forceApprove` — resets allowance to 0, then approves + +All three use `address(token).call(abi.encodeCall(...))` and check the return value: +- If the call reverts: revert with `ERC20CallFailed` +- If the call returns data: decode as `bool` and revert with `ERC20CallFailed` if false +- If the call returns no data: treat as success (handles void-return tokens like old USDC versions) + +`_forceApprove` resets allowance to 0 before setting the new value. This is required for tokens that revert when `approve` is called with a non-zero existing allowance (the USDC implementation has historically had this behaviour in some deployments). The vault always approves exactly `amount` and no more; leftover allowance is not a concern. + +### 9.5 Fee cap + +The `feeRate` is validated at construction against `MAX_FEE_BPS = 1000` (10%). This is an absolute ceiling. The fee rate cannot be raised after deployment. + +Additionally, the fee calculation in `_quoteWithdraw` clamps yield to `max(0, grossAssets - principalPortion)`. This means: +- The fee is always ≤ yield +- The fee is always ≥ 0 +- A loss (grossAssets < principalPortion) results in zero fee, not a negative payout +- Principal is always returned in full regardless of rounding + +### 9.6 No flash loan surface + +The vault does not implement any flash loan interface. There is no `flashLoan`, `flash`, or callback mechanism. The vault's `deposit` and `withdraw` functions are guarded by `nonReentrant`, making flash-loan-style same-transaction manipulation of share price non-viable. + +### 9.7 No governance attack surface + +There are no governance functions, no timelocks, no multisig requirements, and no proposal mechanisms. There is nothing to attack at the governance layer. Protocol changes require deploying a new contract. + +### 9.8 What cannot be done by any address + +These actions are impossible by construction: + +- Withdrawing user funds without the user's private key +- Changing the fee rate after deployment +- Changing the treasury address after deployment +- Pausing or halting deposits or withdrawals (except via Aave pool-level pause) +- Upgrading or replacing the contract logic +- Recovering "stuck" tokens sent to the vault address by mistake (there is no recovery function) + +--- + +## 10. Event Documentation + +### `Deposited` + +```solidity +event Deposited(address indexed user, uint256 assets, uint256 shares) +``` + +Emitted once per successful `deposit` call. + +| Parameter | Type | Indexed | Description | +|---|---|---|---| +| `user` | `address` | Yes | Address that called `deposit` and received the shares | +| `assets` | `uint256` | No | USDC amount deposited, in 6-decimal units | +| `shares` | `uint256` | No | Vault shares minted to `user`, in 6-decimal units | + +**Topic layout:** +``` +topic[0]: keccak256("Deposited(address,uint256,uint256)") + = 0x5548c837ab068cf56a2c2479df0882a4922fd203edb7517321831d95078c5f62 +topic[1]: user address (left-padded to 32 bytes) +data: abi.encode(assets, shares) +``` + +**Use cases:** +- Build a deposit history for a user's address +- Track total USDC deposited into the protocol over time +- Verify a deposit transaction on-chain + +--- + +### `Withdrawn` + +```solidity +event Withdrawn( + address indexed user, + uint256 shares, + uint256 grossAssets, + uint256 fee, + uint256 payout +) +``` + +Emitted once per successful `withdraw` call. + +| Parameter | Type | Indexed | Description | +|---|---|---|---| +| `user` | `address` | Yes | Address that called `withdraw` and received the payout | +| `shares` | `uint256` | No | Number of vault shares redeemed | +| `grossAssets` | `uint256` | No | USDC value of the redeemed shares before fee (6 decimals) | +| `fee` | `uint256` | No | Protocol fee deducted (6 decimals); zero when no yield | +| `payout` | `uint256` | No | Net USDC sent to `user` (`grossAssets − fee`) (6 decimals) | + +**Topic layout:** +``` +topic[0]: keccak256("Withdrawn(address,uint256,uint256,uint256,uint256)") + = 0x884edad9ce6fa2440d8a54cc123490eb96d2768479d49ff9c7366125a9424364 +topic[1]: user address (left-padded to 32 bytes) +data: abi.encode(shares, grossAssets, fee, payout) +``` + +**Use cases:** +- Compute total protocol fees collected: sum `fee` across all `Withdrawn` events +- Compute net yield earned per user: sum `(grossAssets - userDeposits portion)` per address +- Build a complete withdrawal history + +**Querying events with cast:** +```bash +cast logs \ + --address $VAULT \ + --event "Deposited(address,uint256,uint256)" \ + --from-block $DEPLOY_BLOCK \ + --to-block latest \ + --rpc-url $RPC + +cast logs \ + --address $VAULT \ + --event "Withdrawn(address,uint256,uint256,uint256,uint256)" \ + --from-block $DEPLOY_BLOCK \ + --to-block latest \ + --rpc-url $RPC +``` + +--- + +## 11. Deployment Guide + +### 11.1 Pre-deployment checklist + +Before deploying to any network, verify all of the following: + +- [ ] `forge build` completes with zero errors and zero warnings +- [ ] `forge test` passes all tests (including fork tests if targeting a live network) +- [ ] `.env` is populated with the correct values for the target network +- [ ] `TREASURY` is a verified, controlled address — this cannot be changed post-deployment +- [ ] `FEE_RATE_BPS` is confirmed (`500` = 5%; maximum `1000` = 10%) +- [ ] All Aave V3 addresses (USDC, aUSDC, Pool) are verified against the [Aave address book](https://github.com/bgd-labs/aave-address-book) +- [ ] Deployer wallet has sufficient native token (ETH) for gas +- [ ] For mainnet: independent security audit has been completed +- [ ] For mainnet: treasury is a multisig, not a single-key EOA + +### 11.2 Environment variables required + +| Variable | Deployment target | Notes | +|---|---|---| +| `DEPLOYER_PRIVATE_KEY` | All networks except Anvil | Must hold gas on target chain | +| `PRIVATE_KEY` | Anvil only | Any Anvil default key works | +| `TREASURY` | All | Fee recipient — permanent | +| `FEE_RATE_BPS` | All | Default: `500` if unset | +| `ETHERSCAN_API_KEY` | Sepolia, Mainnet, Base Sepolia | Used for block explorer verification | +| `SEPOLIA_RPC_URL` | Sepolia | HTTPS JSON-RPC endpoint | +| `BASE_SEPOLIA_RPC_URL` | Base Sepolia | HTTPS JSON-RPC endpoint | +| `MAINNET_RPC_URL` | Mainnet | HTTPS JSON-RPC endpoint | +| `SEPOLIA_USDC` | Sepolia | Aave testnet USDC | +| `SEPOLIA_AUSDC` | Sepolia | Aave testnet aUSDC | +| `SEPOLIA_AAVE_POOL` | Sepolia | Aave V3 Pool | +| `BASE_SEPOLIA_USDC` | Base Sepolia | (default: `0xba50Cd2A...`) | +| `BASE_SEPOLIA_AUSDC` | Base Sepolia | (default: `0x10F1A9D1...`) | +| `BASE_SEPOLIA_AAVE_POOL` | Base Sepolia | (default: `0x8bAB6d1b...`) | + +### 11.3 Deployment commands + +```bash +# Local Anvil +make anvil # Terminal 1 +make deploy NETWORK=anvil # Terminal 2 + +# Sepolia testnet +make deploy NETWORK=sepolia + +# Base Sepolia testnet +make deploy NETWORK=base-sepolia + +# Mainnet (requires adding chainId 1 to Deploy.s.sol first) +make deploy NETWORK=mainnet +``` + +The deployment script (`script/Deploy.s.sol`): +1. Reads `DEPLOYER_PRIVATE_KEY`, `TREASURY`, `FEE_RATE_BPS` from environment +2. Calls `_loadNetworkConfig(block.chainid)` to get Aave addresses +3. Broadcasts `new YieldSaveVault(...)` via `vm.startBroadcast` +4. Writes `deployments/{network}.json` with `vault`, `chainId`, `block` + +### 11.4 Post-deployment verification + +After deployment, run these checks: + +```bash +VAULT= +RPC= + +# Verify immutable parameters +cast call $VAULT "usdc()(address)" --rpc-url $RPC +cast call $VAULT "aUsdc()(address)" --rpc-url $RPC +cast call $VAULT "aavePool()(address)" --rpc-url $RPC +cast call $VAULT "treasury()(address)" --rpc-url $RPC +cast call $VAULT "feeRate()(uint256)" --rpc-url $RPC + +# Confirm initial state (should all be 0) +cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC +cast call $VAULT "totalShares()(uint256)" --rpc-url $RPC +``` + +Cross-check all addresses against known Aave V3 addresses for the network. Confirm the `deployments/{network}.json` file matches the on-chain address. Confirm block explorer shows verified source code. + +### 11.5 Adding a new network + +1. Add Aave V3 contract addresses to `.env.example` +2. Add a `chainId` branch to `_loadNetworkConfig` in `script/Deploy.s.sol` +3. Add an RPC endpoint to `foundry.toml` under `[rpc_endpoints]` +4. Add Makefile targets for `deploy` and `verify` on the new network +5. Add an empty `deployments/{network}.json` placeholder +6. Verify Aave addresses with `forge script script/VerifyAddresses.s.sol --rpc-url ` + +--- + +## 12. Testing Guide + +### 12.1 Test suite overview + +| Layer | Files | Test count | Dependencies | +|---|---|---|---| +| Unit / constructor | `test/YieldSaveVault.t.sol` | 12 | Mock + fork | +| Deposit scenarios | `test/scenarios/Deposit.t.sol` | 4 | Mock only | +| Withdrawal scenarios | `test/scenarios/Withdraw.t.sol` | 4 | Mock only | +| Fee scenarios | `test/scenarios/Fee.t.sol` | 3 | Mock only | +| Share math scenarios | `test/scenarios/ShareMath.t.sol` | 3 | Mock only | +| Fork integration | `test/fork/BaseSepoliaIntegration.t.sol` | 3 | Live RPC | +| **Total** | | **~29** | | + +Fuzz tests are configured with 256 runs (`foundry.toml: fuzz.runs = 256`). + +### 12.2 Running tests + +```bash +# All tests (fork tests skip if BASE_SEPOLIA_RPC_URL is unset) +forge test + +# Specific file +forge test --match-path test/scenarios/Fee.t.sol + +# Specific function +forge test --match-test test_FullWithdrawalReturnsPrincipalPlusNetYield + +# With call traces (essential for debugging failures) +forge test -vvvv + +# Fork tests (requires BASE_SEPOLIA_RPC_URL) +make fork-base +``` + +### 12.3 Mock infrastructure + +**MockERC20** (`test/mocks/MockERC20.sol`): Minimal ERC-20 with `mint(address, uint256)` and `burn(address, uint256)`. No restrictions on who can call `mint`/`burn` — this is intentional for test flexibility. + +**MockAavePool** (`test/mocks/MockAavePool.sol`): Simulates Aave V3 `supply` and `withdraw` mechanics: +- `supply`: pulls USDC from `onBehalfOf`, mints equal aUSDC to `onBehalfOf` +- `withdraw`: burns aUSDC from caller, transfers USDC to `to` +- `accrueYield(address account, uint256 amount)`: mints aUSDC to `account` and USDC to the pool balance — simulates block-by-block Aave yield accrual + +**AaveFork** (`test/helpers/AaveFork.sol`): Abstract base that deploys `MockERC20` (USDC, 6 decimals), `MockERC20` (aUSDC, 6 decimals), and `MockAavePool`. Defines `alice`, `bob`, `treasury` addresses. + +**Fixtures** (`test/helpers/Fixtures.sol`): Inherits `AaveFork`, deploys `YieldSaveVault` with fee rate 500 (5%), mints 1,000,000 USDC to `alice` and `bob`, and approves the vault for `type(uint256).max`. Provides `_deposit(user, amount)`, `_withdraw(user, shares)`, `_accrueYield(amount)` helpers. + +### 12.4 Coverage targets + +All public and external functions must have: +- At least one success-path test +- At least one test for each revert condition +- At least one test for each boundary condition (zero, first deposit, etc.) + +All internal helper functions (`_safeTransfer`, `_safeTransferFrom`, `_forceApprove`, `_quoteWithdraw`, `_previewDeposit`) are covered via their callers. + +```bash +forge coverage # line and branch summary +forge coverage --report lcov # LCOV report for HTML rendering +``` + +### 12.5 Gas snapshot + +```bash +forge snapshot # regenerates .gas-snapshot +``` + +The `.gas-snapshot` file is committed to version control and serves as a gas regression check. CI fails if the snapshot diverges without a deliberate regeneration. Regenerate and commit the snapshot whenever a change intentionally affects gas costs. + +--- + +## 13. Known Risks & Assumptions + +### 13.1 Protocol assumptions + +These are conditions that must hold for the vault to behave correctly. Violating any of them may result in loss of funds or incorrect accounting. + +| Assumption | Basis | Risk if violated | +|---|---|---| +| Aave V3 correctly maintains `aUsdc.balanceOf(vault)` as the USDC-equivalent claim | Aave V3 design | Share price corrupted, potential under-payment | +| aUSDC is non-deflationary (balance never decreases without a `withdraw` call) | Aave V3 design | Share price decline; user loss | +| USDC `transfer` and `transferFrom` behave as standard ERC-20 | Circle implementation | Payout failures; stuck funds | +| Aave `withdraw` returns at least `grossAssets` USDC when `aUsdc.balanceOf(vault) >= grossAssets` | Aave V3 design | Withdrawal reverts; user unable to withdraw | +| No Aave governance action silently reduces aUSDC balances below the USDC value of the vault | Aave protocol safety | Loss of user funds | + +### 13.2 External protocol risks + +**Aave V3 smart contract risk** +The vault's assets are held inside Aave V3. An exploit, bug, or governance manipulation in Aave V3 could result in partial or total loss of funds. Aave V3 has been live since 2020, has undergone multiple security audits, and holds billions in TVL — but past performance is not a guarantee of future safety. + +**Aave liquidity risk** +When Aave's pool utilisation is 100% (all deposited USDC is borrowed), `aavePool.withdraw` reverts. Withdrawals from the vault are temporarily blocked until utilisation decreases. The vault has no mechanism to force liquidity — users must wait. This is an inherent property of Aave's lending model, not a bug in the vault. + +**aUSDC accounting correctness** +The vault relies entirely on `aUsdc.balanceOf(address(this))` as the source of truth for total assets. If Aave introduces a bug that causes this value to be incorrect (e.g., a precision error in the yield accrual mechanism), share prices and payouts will be wrong. + +### 13.3 Token risks + +**USDC blacklisting** +Circle can blacklist any address at the USDC contract level. If the vault address is blacklisted, all deposits and withdrawals will fail permanently. If a user's address is blacklisted, their withdrawal will fail. There is no mitigation within the vault contract. + +**USDC depeg** +If USDC trades below $1 USD, users' funds are still denominated in USDC — they are not insured against the USD value of their USDC. The vault provides no stablecoin guarantee. + +**USDC upgrade** +Circle has upgraded the USDC contract in the past. A future upgrade that changes `transfer` behaviour could break the vault's ERC-20 interaction. The `_forceApprove` reset-before-approve pattern mitigates one known class of this issue. + +### 13.4 Contract-level risks + +**No upgradeability** +If a bug is found in `YieldSaveVault` after deployment, the contract cannot be patched. A new contract must be deployed and users must migrate manually. See [Maintenance — Re-Deployment and Migration](docs/maintenance.md#re-deployment-and-migration). + +**Rounding behaviour** +Integer division in Solidity truncates (rounds down). This affects: +- `deposit`: shares minted may be slightly fewer than the exact mathematical result. The rounding error is in the protocol's favour (vault accumulates fractional USDC). +- `withdraw`: gross assets may be slightly fewer than the exact value. This is also in the protocol's favour. +- `_quoteWithdraw`: `principalPortion` rounds down, which slightly inflates the yield and thus the fee. The effect is negligible in practice (sub-unit rounding at 6 decimal places). + +Critically, `ZeroSharesMinted` protects against the edge case where `amount * totalShares / totalAssets` rounds to exactly 0 (only possible with very large share prices from accumulated yield and very small deposit amounts). + +**Principal tracking correctness** +`userDeposits` tracks each user's principal for the purpose of fee calculation. If a user makes many partial withdrawals, each one reduces `userDeposits` proportionally. Rounding in this reduction accumulates over many withdrawals, slightly under-recording the principal. This results in a marginally higher fee on future withdrawals — the rounding error is in the protocol's favour. + +**First depositor share price manipulation** +An attacker could theoretically inflate the share price by depositing a small amount and then donating aUSDC directly to the vault (not via `deposit`). This would make the initial share price very high and cause subsequent depositors' shares to round to 0, triggering `ZeroSharesMinted`. This is a known share price inflation attack vector. The current mitigation is `ZeroSharesMinted` (which prevents the victim from depositing at all, rather than silently stealing their funds). Future mitigation options include virtual shares or minimum deposit sizes. + +### 13.5 Operational risks + +**Treasury key compromise** +The treasury address receives protocol fees. If the treasury private key is compromised, the attacker gains access only to future fee payments — they cannot access user funds. Using a multisig treasury mitigates this. + +**Deployer key reuse** +The deployer key is only needed during deployment. After deployment, the key has no special powers. Nevertheless, best practice is to use a dedicated deployment key and not reuse it for operational transactions. + +**No emergency stop** +There is no admin pause function. In the event of a critical vulnerability, the options are: (1) communicate to users to withdraw immediately, (2) contact Aave to pause the relevant pool if the issue is Aave-related, (3) deploy a fixed contract and update frontend pointing to it. There is no in-contract emergency mechanism. + +### 13.6 Audit status + +As of the date of this document, `YieldSaveVault` has not undergone a formal third-party security audit. **Deployment to mainnet should not proceed without an independent audit.** The test suite provides confidence in functional correctness but does not replace a security audit. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9a30d39 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,252 @@ +# Architecture + +## System Overview + +YieldSave is a single-contract protocol. One deployment of `YieldSaveVault` manages all user funds for a given network. There is no proxy, no governance, no admin role, and no upgradeability — all configuration is immutable and set at construction time. + +``` +┌─────────────────────────────────────────────────────────┐ +│ User │ +│ deposit(amount) ──────────────────┐ │ +│ withdraw(shares) ─────────────────┼──────────────────► │ +└────────────────────────────────────┼────────────────────┘ + │ + ┌──────────▼──────────┐ + │ YieldSaveVault │ + │ │ + │ - share accounting │ + │ - fee calculation │ + │ - principal tracking│ + └──────────┬──────────┘ + │ supply / withdraw + ┌──────────▼──────────┐ + │ Aave V3 Pool │ + │ │ + │ USDC ──► aUSDC │ + │ (yield accrues via │ + │ aUSDC rebasing) │ + └─────────────────────┘ +``` + +**Token flow:** +- Deposit: USDC leaves user → enters vault → enters Aave Pool → aUSDC held by vault +- Withdraw: aUSDC burned by Aave Pool → USDC sent from vault → payout to user + fee to treasury + +--- + +## Share Model + +Shares represent proportional ownership of the vault's total assets. They are tracked in mappings — they are not transferable ERC-20 tokens (an intentional simplification for the MVP). + +### First deposit + +When the vault has no shares or no assets, shares are minted 1:1 with the deposit amount: + +``` +shares = amount +``` + +This bootstraps the share price at 1.0 USDC per share. + +### Subsequent deposits + +``` +shares = amount × totalShares / totalAssets +``` + +`totalAssets` is the live aUSDC balance of the vault at the moment of deposit. As Aave accrues interest, `totalAssets` grows while `totalShares` stays constant, so new depositors receive fewer shares per USDC. This is how yield is distributed implicitly — existing shareholders' claims grow as the share price rises. + +### User claim + +``` +userClaim = userShares × totalAssets / totalShares +``` + +A user's USDC claim at any point equals their share of the vault's total assets. + +### Share price appreciation + +``` +sharePrice(t) = totalAssets(t) / totalShares +``` + +`totalShares` only changes on deposit or withdrawal. `totalAssets` grows every block as Aave pays interest. The ratio rises monotonically (barring Aave losses). No explicit yield distribution is needed — it is implicit in the share price. + +--- + +## Fee Model + +The fee is applied only to the yield portion of a withdrawal. Principal is always returned in full. + +### Definitions + +``` +grossAssets = shares × totalAssets / totalShares +principalPortion = userDeposits[user] × shares / userShares[user] +yield = max(0, grossAssets − principalPortion) +fee = yield × feeRate / 10_000 +payout = grossAssets − fee +``` + +### Principal protection guarantee + +`yield` is clamped to `max(0, ...)`. This means: +- If a user withdraws before any yield has accrued, `yield = 0` and `fee = 0`. +- If rounding causes `grossAssets < principalPortion`, the shortfall is absorbed by the protocol (not charged to the user). +- The fee can never exceed yield, and yield can never be negative. + +### Proportional principal reduction + +When a user makes a partial withdrawal, their recorded principal is reduced proportionally to the shares redeemed: + +``` +principalPortion = userDeposits[user] × shares / userShares[user] +userDeposits[user] -= principalPortion +``` + +This preserves accurate principal tracking across multiple partial withdrawals. + +--- + +## State Variables + +```solidity +// Immutable — set at construction, never change +IERC20 public immutable usdc; +IERC20 public immutable aUsdc; +IPool public immutable aavePool; +address public immutable treasury; +uint256 public immutable feeRate; + +// Mutable +uint256 public totalShares; +mapping(address => uint256) public userShares; +mapping(address => uint256) public userDeposits; +``` + +`totalAssets` is not stored — it is always read live from `aUsdc.balanceOf(address(this))`. This ensures the vault always reflects the current Aave balance without needing an update trigger. + +--- + +## Key Invariants + +These properties must hold at all times: + +| Invariant | Expression | +|---|---| +| Total assets is live | `totalAssets == aUsdc.balanceOf(vault)` | +| Shares are fully accounted | `Σ userShares[all] == totalShares` | +| Fee bounded by yield | `fee ≤ max(0, gross − principal)` | +| Principal never penalised | `payout ≥ principalPortion` (when no yield) | +| Share price non-decreasing | `totalAssets / totalShares` grows with Aave APY | + +--- + +## Function Reference + +### Write functions + +| Function | Guard | Effect | +|---|---|---| +| `deposit(uint256 amount)` | `nonReentrant`, amount > 0 | Transfers USDC, supplies to Aave, mints shares | +| `withdraw(uint256 shares)` | `nonReentrant`, shares ≤ userShares | Redeems shares, withdraws from Aave, pays fee, transfers payout | + +### View functions + +| Function | Returns | +|---|---| +| `getVaultBalance()` | Total aUSDC held by vault | +| `getUserBalance(address user)` | Net USDC the user would receive if they withdrew all shares now | +| `previewDeposit(uint256 amount)` | Shares that would be minted | +| `previewWithdraw(uint256 shares)` | Payout `msg.sender` would receive | +| `previewWithdrawFor(address user, uint256 shares)` | Payout, gross assets, and fee for any user | + +### Custom errors + +| Error | When | +|---|---| +| `ZeroAddress()` | Constructor receives `address(0)` | +| `ZeroAmount()` | `deposit` or `withdraw` called with 0 | +| `InvalidFeeRate()` | Constructor `feeRate` > `MAX_FEE_BPS` (1000) | +| `InsufficientShares()` | `withdraw` amount exceeds `userShares[msg.sender]` | +| `ZeroSharesMinted()` | Deposit amount rounds to 0 shares | +| `ERC20CallFailed()` | Any ERC-20 low-level call returns false or reverts | + +--- + +## External Dependencies + +### Aave V3 Pool (`IPool`) + +Two calls are made to Aave: + +```solidity +aavePool.supply(address(usdc), amount, address(this), 0); +aavePool.withdraw(address(usdc), grossAssets, address(this)); +``` + +`supply` mints aUSDC to the vault. `withdraw` burns aUSDC and returns USDC. Both are synchronous and revert on failure. + +**Risk:** If Aave V3 has a bug, the vault's assets are at risk. If Aave's utilisation is 100%, `withdraw` will revert until liquidity returns. + +### aUSDC (`IERC20`) + +The vault reads `aUsdc.balanceOf(address(this))` on every view and state-changing call. This is Aave's interest-bearing wrapper token that rebases upward over time — it is how yield accrues. + +**Risk:** If Aave's aToken has a bug affecting balances, share price calculations will be corrupted. + +### USDC (`IERC20`) + +Standard Circle USDC. The vault uses safe ERC-20 wrappers (`_safeTransfer`, `_safeTransferFrom`, `_forceApprove`) to handle non-standard return value behaviour. + +**Risk:** USDC can be blacklisted by Circle. If the vault address or user address is blacklisted, transfers will fail. Circle insolvency would affect USDC value. + +--- + +## Security Model + +### What is protected + +| Threat | Mitigation | +|---|---| +| Reentrancy attack | `ReentrancyGuard` on `deposit` and `withdraw` | +| Admin rug pull | No admin withdrawal functions exist | +| Fee rate escalation | `feeRate` is immutable; capped at 10% at construction | +| Zero-address misconfiguration | Constructor reverts on any `address(0)` argument | +| Silent ERC-20 failures | All token calls use low-level wrappers that check return values | +| Rounding to zero shares | `deposit` reverts with `ZeroSharesMinted` if `shares == 0` | +| Over-withdrawal | `withdraw` reverts with `InsufficientShares` if `shares > userShares[msg.sender]` | + +### What is not protected + +| Risk | Notes | +|---|---| +| Aave protocol bugs | External dependency; mitigated by Aave V3's track record and audits | +| Aave liquidity crunch | `withdraw` reverts; users must wait for liquidity to free up | +| USDC depeg or blacklist | External dependency; no mitigation in-contract | +| MEV / sandwich attacks | Deposits and withdrawals are permissionless; share price can be front-run at scale | +| No upgradeability | Bugs require re-deployment and migration (see [Maintenance Guide](maintenance.md)) | + +### Non-custodial guarantee + +The vault has no function that allows any address (including the deployer) to withdraw user funds unilaterally. The only paths to funds are: +1. The depositing user calls `withdraw` with their own shares. +2. Aave withdraws funds automatically in a liquidation scenario (not applicable to supply-only positions). + +If the frontend fails, users can recover their funds by calling `withdraw` directly via Etherscan or any EVM wallet. + +--- + +## Design Decisions + +**Why not full ERC-4626?** +ERC-4626 requires shares to be a transferable ERC-20. Adding that increases contract complexity and audit surface. The MVP deliberately omits it to keep the attack surface minimal. ERC-4626 compliance is planned for a future version. + +**Why immutable configuration?** +Mutable configuration (even behind a timelock) creates governance risk and complicates the trust model. For an MVP, immutability provides a simpler and stronger security guarantee. Protocol parameter changes require a new deployment. + +**Why no on-chain oracle?** +Share price is derived from `aUsdc.balanceOf()`, which is the canonical on-chain source of truth for vault assets. No price oracle is needed or appropriate. + +**Why `_forceApprove` resets to 0 first?** +Some ERC-20 implementations reject `approve` calls when the current allowance is non-zero (to prevent certain approval race conditions). Resetting to 0 before approving handles these tokens without conditional logic. diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 0000000..ce1671e --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,1425 @@ +# Contract Reference + +Complete per-contract documentation for every Solidity file in the repository. + +--- + +## Table of Contents + +**Production** +- [IERC20](#ierc20--srcinterfacesierc20sol) +- [IPool](#ipool--srcinterfacesipoolsol) +- [YieldSaveVault](#yieldsavevault--srcyieldsavevaultsol) + +**Test infrastructure** +- [MockERC20](#mockerc20--testmocksmockerc20sol) +- [MockAavePool](#mockaavepool--testmocksmockaavepoolsol) +- [AaveFork](#aavefork--testhelpersaaveforksol) +- [Fixtures](#fixtures--testhelpersfixtressol) +- [BaseSepoliaFork](#basesepoliafork--testhelpersbasesepoliaforksol) + +**Test suites** +- [YieldSaveVaultTest](#yieldsavevaulttest--testyieldsavevaulttsol) +- [DepositScenariosTest](#depositscenariosttest--testscenariosdeposittest) +- [WithdrawScenariosTest](#withdrawscenariostest--testscenarioswithdrawttest) +- [FeeScenariosTest](#feescenariostest--testscenariosfeettest) +- [ShareMathScenariosTest](#sharemathscenariostest--testscenariossharemath-ttestsol) + +**Deployment scripts** +- [Deploy](#deploy--scriptdeploysstol) +- [VerifyAddresses](#verifyaddresses--scriptverifyaddressesssol) + +--- + +## System Interaction Overview + +The diagram below shows every contract in the repository, the direction of calls at runtime, and the boundaries between production code, test infrastructure, and deployment scripts. + +``` +╔══════════════════════════════════════════════════════════════════════╗ +║ PRODUCTION (src/) ║ +║ ║ +║ ┌─────────────────────────────────────────────────────────────┐ ║ +║ │ YieldSaveVault │ ║ +║ │ │ ║ +║ │ implements: │ ║ +║ │ ReentrancyGuard (OZ v5) │ ║ +║ │ │ ║ +║ │ uses interfaces: │ ║ +║ │ IERC20 ──────────────────────────► USDC (external) │ ║ +║ │ IERC20 ──────────────────────────► aUSDC (external) │ ║ +║ │ IPool ──────────────────────────► Aave V3 Pool (ext.) │ ║ +║ └─────────────────────────────────────────────────────────────┘ ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════╝ + +╔══════════════════════════════════════════════════════════════════════╗ +║ TEST INFRASTRUCTURE (test/) ║ +║ ║ +║ AaveFork ──► MockERC20 (USDC) MockAavePool ║ +║ │ ──► MockERC20 (aUSDC) ──► implements IPool ║ +║ │ └─► owns MockERC20 refs ║ +║ ▼ ║ +║ Fixtures ──► YieldSaveVault (deploys with mock addresses) ║ +║ │ ──► _deposit / _withdraw / _accrueYield helpers ║ +║ │ ║ +║ BaseSepoliaFork ──► YieldSaveVault (deploys against live Aave) ║ +║ │ ──► real USDC / aUSDC / Aave Pool (forked chain) ║ +║ │ ║ +║ Test suites inherit one of: ║ +║ Fixtures → mock-based tests ║ +║ BaseSepoliaFork → fork-based tests ║ +║ Test (forge-std) → YieldSaveVaultTest (own fork setup) ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════╝ + +╔══════════════════════════════════════════════════════════════════════╗ +║ DEPLOYMENT SCRIPTS (script/) ║ +║ ║ +║ Deploy ──────────────► new YieldSaveVault(...) ║ +║ │ reads env vars └─► writes deployments/{network}.json ║ +║ │ ║ +║ VerifyAddresses ──────► logs Aave addresses from env vars ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════╝ +``` + +--- + +## IERC20 — `src/interfaces/IERC20.sol` + +### Purpose + +A minimal ERC-20 interface declaring only the six functions that `YieldSaveVault` needs to call or inspect on USDC and aUSDC. It is not a full ERC-20 implementation — it is a Solidity type declaration used to give the compiler knowledge of the external function signatures. + +Defining a minimal interface rather than importing a full ERC-20 contract keeps compilation fast, eliminates unnecessary ABI encoding surface, and makes the vault's token dependencies explicit. + +### State variables + +None. Interfaces cannot have state variables. + +### Constructor + +None. Interfaces cannot be deployed. + +### Functions + +| Signature | Visibility | Description | +|---|---|---| +| `totalSupply()` | `external view` | Returns the total token supply | +| `balanceOf(address account)` | `external view` | Returns the token balance of `account` | +| `allowance(address owner, address spender)` | `external view` | Returns remaining approved amount | +| `transfer(address to, uint256 value)` | `external` | Transfers `value` tokens from `msg.sender` to `to` | +| `approve(address spender, uint256 value)` | `external` | Approves `spender` to spend `value` tokens on behalf of `msg.sender` | +| `transferFrom(address from, address to, uint256 value)` | `external` | Transfers `value` tokens from `from` to `to` using allowance | + +### Access restrictions + +Not applicable — this is an interface, not a deployed contract. + +### Internal logic flow + +Not applicable — interface functions have no implementation. + +### Events + +None declared. (The full ERC-20 `Transfer` and `Approval` events are not needed by the vault, so they are omitted.) + +### Revert conditions + +Defined by the concrete implementations (USDC, aUSDC, MockERC20), not by this interface. + +### Interaction diagram + +``` + YieldSaveVault (caller) + │ + │ IERC20(usdc).transferFrom(...) + │ IERC20(usdc).approve(...) + │ IERC20(usdc).transfer(...) + │ IERC20(aUsdc).balanceOf(...) + │ + ▼ + [ Concrete ERC-20 implementation ] + USDC (Circle) on mainnet/testnet + MockERC20 in unit tests +``` + +--- + +## IPool — `src/interfaces/IPool.sol` + +### Purpose + +A minimal Aave V3 Pool interface declaring only the two functions that `YieldSaveVault` calls: `supply` (deposit USDC into Aave and receive aUSDC) and `withdraw` (redeem aUSDC for USDC). The full Aave V3 Pool ABI has dozens of functions; defining only what is needed minimises compilation overhead and makes the dependency surface explicit. + +### State variables + +None. + +### Constructor + +None. + +### Functions + +| Signature | Visibility | Description | +|---|---|---| +| `supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)` | `external` | Deposits `amount` of `asset` into Aave on behalf of `onBehalfOf`; mints aTokens to `onBehalfOf` | +| `withdraw(address asset, uint256 amount, address to)` | `external returns (uint256)` | Redeems `amount` of `asset` from Aave, sends underlying tokens to `to`; returns actual amount withdrawn | + +**`supply` parameter notes:** +- `onBehalfOf`: the address that receives the aTokens — the vault always passes `address(this)` +- `referralCode`: Aave referral program identifier — the vault always passes `0` + +**`withdraw` return value:** Aave returns the actual USDC amount withdrawn, which may differ slightly from `amount` due to Aave's internal rounding. The vault does not use the return value. + +### Access restrictions + +Not applicable. + +### Internal logic flow + +Not applicable. + +### Events + +None declared. (Aave emits its own events from the concrete Pool contract.) + +### Revert conditions + +Defined by the Aave V3 Pool implementation. Most relevant to the vault: +- `supply` reverts if the asset is not supported or the pool is paused +- `withdraw` reverts if there is insufficient liquidity in the pool (utilisation = 100%) + +### Interaction diagram + +``` + YieldSaveVault (caller) + │ + │ IPool(aavePool).supply(usdc, amount, vault, 0) + │ IPool(aavePool).withdraw(usdc, grossAssets, vault) + │ + ▼ + [ Concrete Aave V3 Pool ] + Aave V3 Pool on mainnet/testnet + MockAavePool in unit tests +``` + +--- + +## YieldSaveVault — `src/YieldSaveVault.sol` + +### Purpose + +The core protocol contract. It accepts USDC deposits from users, routes them into Aave V3 to earn yield, tracks each user's proportional ownership via non-transferable shares, and returns principal plus net yield (minus a protocol fee) on withdrawal. All protocol configuration is immutable and set at construction. + +### Inheritance + +``` +YieldSaveVault + └── ReentrancyGuard (OpenZeppelin v5.6.1) + └── storage: _status (slot 0) +``` + +### State variables + +**Constants** (not in storage — inlined as bytecode literals): + +| Name | Value | Description | +|---|---|---| +| `BPS_DENOMINATOR` | `10_000` | Basis point denominator for fee calculations | +| `MAX_FEE_BPS` | `1_000` | Maximum permitted fee rate (10%) | + +**Immutables** (not in storage — embedded in deployed bytecode): + +| Name | Type | Description | +|---|---|---| +| `usdc` | `IERC20` | The deposit/withdrawal token (USDC) | +| `aUsdc` | `IERC20` | Aave's interest-bearing aUSDC token | +| `aavePool` | `IPool` | Aave V3 Pool | +| `treasury` | `address` | Protocol fee recipient | +| `feeRate` | `uint256` | Fee rate in basis points (e.g. 500 = 5%) | + +**Storage** (EVM storage slots, post-inheritance): + +| Slot | Name | Type | Description | +|---|---|---|---| +| 0 | `_status` | `uint256` | ReentrancyGuard sentinel (1 = idle, 2 = entered) | +| 1 | `totalShares` | `uint256` | Sum of all outstanding shares | +| 2 | `userShares` | `mapping(address ⇒ uint256)` | Per-user share balance | +| 3 | `userDeposits` | `mapping(address ⇒ uint256)` | Per-user cumulative principal (USDC, 6 dp) | + +### Constructor + +```solidity +constructor( + address usdc_, + address aUsdc_, + address aavePool_, + address treasury_, + uint256 feeRate_ +) +``` + +**Validation:** + +``` +if usdc_ == address(0) → revert ZeroAddress() +if aUsdc_ == address(0) → revert ZeroAddress() +if aavePool_ == address(0) → revert ZeroAddress() +if treasury_ == address(0) → revert ZeroAddress() +if feeRate_ > MAX_FEE_BPS → revert InvalidFeeRate() +``` + +All five checks must pass before any assignment occurs. If any address is `address(0)`, the entire deployment reverts. All five values are written to immutable storage exactly once and cannot be changed. + +### Access restrictions + +There are no roles, no `onlyOwner`, and no `onlyAdmin` modifiers. Every external function is callable by any address. The only access control is economic: `withdraw` requires the caller to hold the shares they are redeeming (`userShares[msg.sender] >= shares`). + +### Important functions + +--- + +#### `deposit(uint256 amount) → uint256 shares` + +**Visibility:** `external nonReentrant` +**Purpose:** Transfer `amount` USDC from the caller into the vault, supply it to Aave, mint proportional shares. + +**Logic flow:** + +``` +deposit(amount) + │ + ├─ [GUARD] amount == 0 → revert ZeroAmount + │ + ├─ snapshot = _totalAssets() // read aUsdc.balanceOf(vault) BEFORE transfer + │ + ├─ shares = _previewDeposit(amount, snapshot) + │ if totalShares == 0 or snapshot == 0: shares = amount (1:1 first deposit) + │ else: shares = amount * totalShares / snapshot + │ + ├─ [GUARD] shares == 0 → revert ZeroSharesMinted + │ + ├─ _safeTransferFrom(usdc, msg.sender, vault, amount) + │ low-level call: usdc.transferFrom(msg.sender, vault, amount) + │ revert ERC20CallFailed if call fails or returns false + │ + ├─ _forceApprove(usdc, aavePool, amount) + │ low-level call: usdc.approve(aavePool, 0) // reset first + │ low-level call: usdc.approve(aavePool, amount) // then set + │ revert ERC20CallFailed on failure + │ + ├─ aavePool.supply(usdc, amount, vault, 0) + │ Aave mints aUsdc to vault equal to amount + │ + ├─ [STATE UPDATE] + │ userShares[msg.sender] += shares + │ userDeposits[msg.sender] += amount + │ totalShares += shares + │ + └─ emit Deposited(msg.sender, amount, shares) + return shares +``` + +**Why `snapshot` is taken before the transfer:** +If `_totalAssets()` were read after the USDC transfer, the aUSDC balance would be stale from the previous block and the new USDC would not yet appear as aUSDC. Taking the snapshot before any state change ensures the share price used reflects the vault's actual aUSDC position at the time the user initiates the deposit. + +**Revert conditions:** + +| Error | Condition | +|---|---| +| `ZeroAmount` | `amount == 0` | +| `ZeroSharesMinted` | Computed `shares == 0` (only possible with dust amounts and a very high share price) | +| `ERC20CallFailed` | USDC `transferFrom` or `approve` fails | +| *(Aave revert)* | Aave's `supply` reverts (e.g. pool paused, asset not supported) | + +--- + +#### `withdraw(uint256 shares) → uint256 payout` + +**Visibility:** `external nonReentrant` +**Purpose:** Redeem `shares` from the caller, withdraw the proportional USDC from Aave, deduct the fee on yield, send payout to caller and fee to treasury. + +**Logic flow:** + +``` +withdraw(shares) + │ + ├─ [GUARD] shares == 0 → revert ZeroAmount + │ + ├─ userShareBalance = userShares[msg.sender] + │ + ├─ [GUARD] shares > userShareBalance → revert InsufficientShares + │ + ├─ (grossAssets, principalPortion, fee) = _quoteWithdraw( + │ msg.sender, shares, _totalAssets(), totalShares, userShareBalance) + │ + │ grossAssets = shares * totalAssets / totalShares + │ principalPortion = userDeposits[user] * shares / userShareBalance + │ yield = grossAssets > principalPortion + │ ? grossAssets - principalPortion : 0 + │ fee = yield * feeRate / BPS_DENOMINATOR + │ + ├─ payout = grossAssets - fee + │ + ├─ [STATE UPDATE — before external calls] + │ userShares[msg.sender] = userShareBalance - shares + │ userDeposits[msg.sender] -= principalPortion + │ totalShares -= shares + │ + ├─ aavePool.withdraw(usdc, grossAssets, vault) + │ Aave burns vault's aUsdc and transfers USDC to vault + │ + ├─ _safeTransfer(usdc, msg.sender, payout) + │ + ├─ if fee != 0: _safeTransfer(usdc, treasury, fee) + │ + └─ emit Withdrawn(msg.sender, shares, grossAssets, fee, payout) + return payout +``` + +**Checks-effects-interactions ordering:** +State (`userShares`, `userDeposits`, `totalShares`) is updated in step 5, before the Aave withdrawal and token transfers in steps 6–8. This is the correct pattern — even without `nonReentrant`, a reentrant call after step 5 would see the updated (reduced) share balance and be unable to re-use the same shares. + +**Revert conditions:** + +| Error | Condition | +|---|---| +| `ZeroAmount` | `shares == 0` | +| `InsufficientShares` | `shares > userShares[msg.sender]` | +| `ERC20CallFailed` | Any USDC `transfer` call fails | +| *(Aave revert)* | Aave `withdraw` reverts — most commonly due to zero liquidity (utilisation 100%) | + +--- + +#### `getVaultBalance() → uint256` + +**Visibility:** `external view` + +Returns `aUsdc.balanceOf(address(this))`. The aUSDC balance of the vault equals the sum of all user deposits plus all accrued Aave yield. It increases every block without any transaction. + +--- + +#### `getUserBalance(address user) → uint256` + +**Visibility:** `external view` + +Returns the net USDC payout `user` would receive if they withdrew all shares right now (after fee deduction). Returns `0` if `userShares[user] == 0`. + +Calls `_previewWithdrawForUser(user, userShares[user])` and returns `payout`. + +--- + +#### `previewDeposit(uint256 amount) → uint256` + +**Visibility:** `external view` + +Returns the number of shares that would be minted for `amount` USDC at the current share price. Does not modify state. Reflects the same logic as `deposit` without executing it. + +--- + +#### `previewWithdraw(uint256 shares) → uint256` + +**Visibility:** `external view` + +Returns the net payout `msg.sender` would receive for redeeming `shares`. Returns `0` if invalid (zero shares, no balance, or shares exceed balance). + +--- + +#### `previewWithdrawFor(address user, uint256 shares) → (uint256 payout, uint256 grossAssets, uint256 fee)` + +**Visibility:** `external view` + +Full three-component withdrawal preview for any `user`. Returns `(0, 0, 0)` on any invalid input. + +--- + +#### Internal: `_previewDeposit(uint256 amount, uint256 assetsBefore) → uint256` + +**Visibility:** `internal view` + +``` +if amount == 0: return 0 +if totalShares == 0 or assetsBefore == 0: return amount ← first deposit: 1:1 +else: return amount * totalShares / assetsBefore +``` + +`assetsBefore` is the aUSDC balance snapshot captured before the USDC transfer in `deposit`. This prevents the share price from being computed on a stale or manipulated balance. + +--- + +#### Internal: `_quoteWithdraw(...) → (uint256 grossAssets, uint256 principalPortion, uint256 fee)` + +**Visibility:** `internal view` + +``` +grossAssets = shares * assets / currentTotalShares +principalPortion = userDeposits[user] * shares / userShareBalance +yield = grossAssets > principalPortion ? grossAssets - principalPortion : 0 +fee = yield * feeRate / BPS_DENOMINATOR +``` + +The yield clamp (`max(0, ...)`) ensures the fee is always non-negative and never exceeds the yield portion. A user who earns no yield pays no fee. + +--- + +#### Internal: `_previewWithdrawForUser(address user, uint256 shares) → (uint256 payout, uint256 grossAssets, uint256 fee)` + +**Visibility:** `internal view` + +Guard wrapper around `_quoteWithdraw`. Returns `(0, 0, 0)` when: `shares == 0`, `userShareBalance == 0`, or `shares > userShareBalance`. Otherwise delegates to `_quoteWithdraw` and computes `payout = grossAssets - fee`. + +--- + +#### Internal: `_totalAssets() → uint256` + +**Visibility:** `internal view` + +Returns `aUsdc.balanceOf(address(this))`. The single source of truth for total vault assets. Never cached. + +--- + +#### Internal: `_safeTransfer(IERC20 token, address to, uint256 amount)` + +**Visibility:** `internal` + +Low-level ERC-20 transfer. Uses `address(token).call(abi.encodeCall(IERC20.transfer, (to, amount)))`. + +``` +(success, data) = address(token).call(encodeCall(transfer, (to, amount))) +if !success: revert ERC20CallFailed +if data.length != 0 and !decode: revert ERC20CallFailed +// if data.length == 0: treat as success (void-return tokens) +``` + +--- + +#### Internal: `_safeTransferFrom(IERC20 token, address from, address to, uint256 amount)` + +**Visibility:** `internal` + +Identical pattern to `_safeTransfer` but encodes `transferFrom(from, to, amount)`. + +--- + +#### Internal: `_forceApprove(IERC20 token, address spender, uint256 amount)` + +**Visibility:** `internal` + +``` +call: token.approve(spender, 0) // reset — handles tokens that revert on non-zero approve + └─ revert ERC20CallFailed on failure +call: token.approve(spender, amount) // set desired allowance + └─ revert ERC20CallFailed on failure +``` + +The two-step reset is required for USDC compatibility. Some USDC deployments revert if `approve` is called when the existing allowance is non-zero. Resetting to 0 first handles this without conditional logic. + +### Events + +#### `Deposited(address indexed user, uint256 assets, uint256 shares)` + +| Field | Type | Indexed | Description | +|---|---|---|---| +| `user` | `address` | Yes | Caller of `deposit` | +| `assets` | `uint256` | No | USDC deposited (6 decimals) | +| `shares` | `uint256` | No | Shares minted | + +#### `Withdrawn(address indexed user, uint256 shares, uint256 grossAssets, uint256 fee, uint256 payout)` + +| Field | Type | Indexed | Description | +|---|---|---|---| +| `user` | `address` | Yes | Caller of `withdraw` | +| `shares` | `uint256` | No | Shares redeemed | +| `grossAssets` | `uint256` | No | USDC value of redeemed shares before fee | +| `fee` | `uint256` | No | Protocol fee sent to treasury | +| `payout` | `uint256` | No | Net USDC sent to user | + +### Interaction diagrams + +#### deposit() call flow + +``` + User YieldSaveVault USDC (ERC-20) Aave V3 Pool + │ │ │ │ + │ ① deposit(amount) │ │ │ + │──────────────────────────►│ │ │ + │ │ ② _totalAssets() │ │ + │ │──────────────────────────►│ balanceOf(vault) │ + │ │◄──────────────────────────│ │ + │ │ │ │ + │ │ ③ _previewDeposit(amount, snapshot) │ + │ │ (internal — no external call) │ + │ │ │ │ + │ │ ④ _safeTransferFrom() │ │ + │ │──────────────────────────►│ transferFrom │ + │ │ (USDC: user → vault) │ (user → vault) │ + │ │◄──────────────────────────│ │ + │ │ │ │ + │ │ ⑤ _forceApprove() │ │ + │ │──────────────────────────►│ approve(pool, 0) │ + │ │──────────────────────────►│ approve(pool, amt) │ + │ │ │ │ + │ │ ⑥ supply(usdc,amt,vault,0)│ │ + │ │───────────────────────────────────────────────►│ + │ │ (aUSDC minted to vault) │ │◄─ aUsdc.mint(vault, amt) + │ │◄───────────────────────────────────────────────│ + │ │ │ │ + │ │ ⑦ state updates │ │ + │ │ userShares += shares │ │ + │ │ userDeposits += amount │ │ + │ │ totalShares += shares │ │ + │ │ │ │ + │ ⑧ emit Deposited(...) │ │ │ + │◄──────────────────────────│ │ │ + │ returns shares │ │ │ +``` + +#### withdraw() call flow + +``` + User YieldSaveVault USDC (ERC-20) Aave V3 Pool Treasury + │ │ │ │ │ + │ ① withdraw(shares) │ │ │ │ + │──────────────────────────►│ │ │ │ + │ │ ② _totalAssets() │ │ │ + │ │──────────────────────────►│ balanceOf(vault) │ │ + │ │◄──────────────────────────│ │ │ + │ │ │ │ │ + │ │ ③ _quoteWithdraw(...) │ │ │ + │ │ (internal) │ │ │ + │ │ grossAssets, fee, payout│ │ │ + │ │ │ │ │ + │ │ ④ state updates │ │ │ + │ │ userShares -= shares │ │ │ + │ │ userDeposits -= principal │ + │ │ totalShares -= shares │ │ │ + │ │ │ │ │ + │ │ ⑤ withdraw(usdc,gross,vault) │ │ + │ │───────────────────────────────────────────────►│ │ + │ │ (USDC returned to vault)│ │◄─ aUsdc.burn │ + │ │◄───────────────────────────────────────────────│ │ + │ │ │ │ │ + │ │ ⑥ _safeTransfer(payout) │ │ │ + │ │──────────────────────────►│ transfer │ │ + │ │ (USDC: vault → user) │ (vault → user) │ │ + │◄──────────────────────────│◄──────────────────────────│ │ │ + │ │ │ │ │ + │ │ ⑦ _safeTransfer(fee) │ │ │ + │ │──────────────────────────►│ transfer │ │ + │ │ (USDC: vault → treasury)│ (vault → treasury) │ │◄─ fee arrives + │ │ │ │ │ + │ ⑧ emit Withdrawn(...) │ │ │ │ + │◄──────────────────────────│ │ │ │ + │ returns payout │ │ │ │ +``` + +#### View function flow (read-only, no external writes) + +``` + Caller YieldSaveVault aUSDC (ERC-20) + │ │ │ + │ getUserBalance(user) │ │ + │──────────────────────────►│ │ + │ │ _totalAssets() │ + │ │──────────────────────────►│ balanceOf(vault) + │ │◄──────────────────────────│ + │ │ _previewWithdrawForUser(user, userShares[user]) + │ │ _quoteWithdraw(...) + │ │ payout = gross - fee + │◄──────────────────────────│ + │ returns payout │ +``` + +--- + +## MockERC20 — `test/mocks/MockERC20.sol` + +### Purpose + +A minimal, unrestricted ERC-20 token for use in tests. It fully implements the `IERC20` interface plus `mint` and `burn` functions that have no access control — any test can create or destroy tokens freely. It is used as both the USDC stand-in and the aUSDC stand-in in mock-based tests. + +### State variables + +| Name | Type | Visibility | Description | +|---|---|---|---| +| `name` | `string` | `public` | Token name | +| `symbol` | `string` | `public` | Token symbol | +| `decimals` | `uint8` | `public immutable` | Decimal places (6 for USDC/aUSDC stand-ins) | +| `totalSupply` | `uint256` | `public` | Total supply, kept in sync by mint/burn | +| `balanceOf` | `mapping(address ⇒ uint256)` | `public` | Per-address balance | +| `allowance` | `mapping(address ⇒ mapping(address ⇒ uint256))` | `public` | Per-address per-spender allowance | + +### Constructor + +```solidity +constructor(string memory name_, string memory symbol_, uint8 decimals_) +``` + +Sets `name`, `symbol`, and `decimals`. No validation — any values accepted. + +### Access restrictions + +None. `mint` and `burn` are callable by any address. This is intentional for test flexibility. + +### Important functions + +| Function | Description | +|---|---| +| `transfer(to, value)` | Calls `_transfer(msg.sender, to, value)`. Always returns `true`. | +| `approve(spender, value)` | Sets `allowance[msg.sender][spender] = value`. Returns `true`. | +| `transferFrom(from, to, value)` | Reduces allowance (if not `type(uint256).max`), then calls `_transfer`. Returns `true`. | +| `mint(to, value)` | Increases `totalSupply` and `balanceOf[to]` by `value`. | +| `burn(from, value)` | Decreases `balanceOf[from]` and `totalSupply` by `value`. | + +#### Internal: `_transfer(from, to, value)` + +``` +balanceOf[from] -= value // underflows if insufficient — Solidity 0.8 will revert +balanceOf[to] += value +``` + +No events are emitted (unlike a real ERC-20). For test purposes, event emission is not required. + +### Revert conditions + +| Condition | Cause | +|---|---| +| `transfer` or `transferFrom` with insufficient balance | Solidity 0.8 checked arithmetic underflow on `balanceOf[from] -= value` | +| `burn` with insufficient balance | Same — underflow on `balanceOf[from] -= value` | +| `transferFrom` with insufficient allowance (non-max) | Underflow on `allowance[from][msg.sender] -= value` | + +No custom errors — reverts with the default arithmetic panic. + +### Events + +None. Unlike a production ERC-20, this mock omits `Transfer` and `Approval` events. + +### Interaction diagram + +``` + Test contract / Fixtures + │ + │ usdc.mint(alice, 1_000_000e6) + │ aUsdc.mint(vault, amount) ← called by MockAavePool.supply + │ aUsdc.burn(vault, amount) ← called by MockAavePool.withdraw + │ + ▼ + MockERC20 (USDC or aUSDC instance) + │ + │ Read by YieldSaveVault: + │ aUsdc.balanceOf(vault) → _totalAssets() + │ + │ Written by YieldSaveVault (via IERC20 interface): + │ usdc.transferFrom(user, vault, amount) → deposit + │ usdc.approve(pool, amount) → deposit + │ usdc.transfer(user, payout) → withdraw + │ usdc.transfer(treasury, fee) → withdraw +``` + +--- + +## MockAavePool — `test/mocks/MockAavePool.sol` + +### Purpose + +A deterministic Aave V3 Pool substitute for unit tests. It implements `IPool` and mirrors Aave's deposit/withdrawal mechanics without any real asset management. It also exposes `accrueYield`, a test-only function that simulates interest accumulation by minting aUSDC directly to the vault. + +### State variables + +| Name | Type | Visibility | Description | +|---|---|---|---| +| `usdc` | `IERC20` | `public immutable` | Reference to the mock USDC token | +| `aUsdc` | `MockERC20` | `public immutable` | Reference to the mock aUSDC token (typed as `MockERC20` to access `mint`/`burn`) | + +### Constructor + +```solidity +constructor(address usdc_, address aUsdc_) +``` + +Stores both token addresses. No validation. + +### Access restrictions + +None. All functions callable by any address. In practice only `YieldSaveVault` calls `supply` and `withdraw`; only test contracts call `accrueYield`. + +### Important functions + +#### `supply(address asset, uint256 amount, address onBehalfOf, uint16)` + +``` +require asset == usdc // guard against wrong asset +usdc.transferFrom(msg.sender → pool, amount) // pull USDC from vault +aUsdc.mint(onBehalfOf, amount) // mint equivalent aUSDC to vault +``` + +Mirrors Aave's behaviour: caller provides USDC allowance, pool pulls the USDC, and aTokens appear in `onBehalfOf`'s balance. + +#### `withdraw(address asset, uint256 amount, address to)` + +``` +require asset == usdc +aUsdc.burn(msg.sender, amount) // destroy vault's aUSDC +usdc.transfer(to, amount) // return USDC to vault +return amount +``` + +Mirrors Aave's behaviour: aTokens are burned from the caller (vault), USDC is transferred to `to`. + +#### `accrueYield(address account, uint256 amount)` *(test-only)* + +``` +aUsdc.mint(account, amount) // simulate aUSDC balance increase (yield on account) +MockERC20(usdc).mint(pool, amount) // give pool enough USDC to cover future withdrawals +``` + +This function does not exist on real Aave. It simulates the block-by-block rebasing of aUSDC by directly minting tokens. The pool also mints USDC to itself to remain solvent for subsequent `withdraw` calls. + +### Events + +None. + +### Revert conditions + +| Condition | Source | +|---|---| +| `asset != usdc` in `supply` or `withdraw` | `require` string revert: `"unsupported asset"` | +| USDC `transferFrom` fails in `supply` | `require(result, "transferFrom failed")` | +| USDC `transfer` fails in `withdraw` | `require(result, "transfer failed")` | + +### Interaction diagram + +``` + YieldSaveVault MockAavePool MockERC20 (USDC) MockERC20 (aUSDC) + │ │ │ │ + │ supply(usdc,amt,vault,0) │ │ │ + │───────────────────────────►│ │ │ + │ │ usdc.transferFrom(vault → pool, amt) │ + │ │──────────────────────────►│ │ + │ │ aUsdc.mint(vault, amt) │ │ + │ │───────────────────────────────────────────────►│ + │◄───────────────────────────│ │ │ + │ │ │ │ + │ withdraw(usdc,gross,vault)│ │ │ + │───────────────────────────►│ │ │ + │ │ aUsdc.burn(vault, gross) │ │ + │ │───────────────────────────────────────────────►│ + │ │ usdc.transfer(vault, gross) │ + │ │──────────────────────────►│ │ + │◄───────────────────────────│ │ │ + │ │ │ │ + Test contract │ │ │ + │ accrueYield(vault, amt) │ │ │ + │───────────────────────────►│ │ │ + │ │ aUsdc.mint(vault, amt) │ │ + │ │───────────────────────────────────────────────►│ + │ │ usdc.mint(pool, amt) │ │ + │ │──────────────────────────►│ │ + │◄───────────────────────────│ │ │ +``` + +--- + +## AaveFork — `test/helpers/AaveFork.sol` + +### Purpose + +Abstract base contract providing the mock token and pool infrastructure shared by all scenario-based tests. It is responsible for deploying fresh `MockERC20` and `MockAavePool` instances in `setUp`, and for defining the shared test constants and named test addresses. `Fixtures` inherits from `AaveFork` and adds the vault deployment on top. + +### State variables + +| Name | Type | Visibility | Value / Description | +|---|---|---|---| +| `USDC_UNIT` | `uint256` | `internal constant` | `1e6` — one USDC in base units | +| `FEE_RATE_BPS` | `uint256` | `internal constant` | `500` — 5% fee used in all mock tests | +| `alice` | `address` | `internal` | `makeAddr("alice")` — deterministic test address | +| `bob` | `address` | `internal` | `makeAddr("bob")` — deterministic test address | +| `treasury` | `address` | `internal` | `makeAddr("treasury")` — fee recipient in tests | +| `usdc` | `MockERC20` | `internal` | The mock USDC token (6 decimals) | +| `aUsdc` | `MockERC20` | `internal` | The mock aUSDC token (6 decimals) | +| `pool` | `MockAavePool` | `internal` | The mock Aave Pool | + +### Constructor / Initializer + +`AaveFork` has no constructor. It uses `setUp()` (Foundry's test initializer): + +```solidity +function setUp() public virtual { + usdc = new MockERC20("USD Coin", "USDC", 6); + aUsdc = new MockERC20("Aave USDC", "aUSDC", 6); + pool = new MockAavePool(address(usdc), address(aUsdc)); +} +``` + +Marked `virtual` so `Fixtures` (and any other inheritor) can call `super.setUp()` and extend it. + +### Access restrictions + +Abstract — cannot be deployed directly. + +### Events + +None. + +### Revert conditions + +None. `setUp` does not validate anything. + +### Interaction diagram + +``` + Foundry test runner + │ + │ setUp() + ▼ + AaveFork.setUp() + │ + ├── new MockERC20("USDC", 6) ──► usdc + ├── new MockERC20("aUSDC", 6) ──► aUsdc + └── new MockAavePool(usdc, aUsdc) ──► pool + + ▼ (inherited by) + Fixtures.setUp() (calls super.setUp() first, then deploys vault) +``` + +--- + +## Fixtures — `test/helpers/Fixtures.sol` + +### Purpose + +The primary shared test setup for all scenario-based tests. Inherits `AaveFork` (mock tokens + pool), then deploys a `YieldSaveVault` configured against those mocks, pre-funds `alice` and `bob` with 1,000,000 USDC each, and pre-approves infinite allowances. Provides three helper functions that all scenario tests use to compose test scenarios without boilerplate. + +### State variables + +Inherits all of `AaveFork`'s state, plus: + +| Name | Type | Visibility | Description | +|---|---|---|---| +| `vault` | `YieldSaveVault` | `internal` | The vault under test | + +### Constructor / Initializer + +```solidity +function setUp() public virtual override { + super.setUp(); // deploys MockERC20 × 2, MockAavePool + + vault = new YieldSaveVault( + address(usdc), address(aUsdc), address(pool), treasury, FEE_RATE_BPS + ); + + _mintAndApprove(alice, 1_000_000 * USDC_UNIT); + _mintAndApprove(bob, 1_000_000 * USDC_UNIT); +} +``` + +`_mintAndApprove(user, amount)`: +``` +usdc.mint(user, amount) +vm.prank(user) +usdc.approve(vault, type(uint256).max) +``` + +### Helper functions + +#### `_deposit(address user, uint256 amount) → uint256 shares` + +``` +vm.prank(user) +shares = vault.deposit(amount) +``` + +Wraps `vault.deposit` with the correct `msg.sender`. Allowance is already set to max in `setUp`. + +#### `_withdraw(address user, uint256 shares) → uint256 payout` + +``` +vm.prank(user) +payout = vault.withdraw(shares) +``` + +Wraps `vault.withdraw` with the correct `msg.sender`. + +#### `_accrueYield(uint256 amount)` + +``` +pool.accrueYield(address(vault), amount) +``` + +Mints `amount` aUSDC to the vault and `amount` USDC to the pool — simulating Aave yield accrual without advancing blocks. + +### Access restrictions + +Abstract — cannot be deployed directly. + +### Events + +None. + +### Revert conditions + +None in `setUp` itself. `_deposit` and `_withdraw` will propagate reverts from `YieldSaveVault` if the test passes invalid inputs. + +### Interaction diagram + +``` + Scenario test contract (inherits Fixtures) + │ + │ setUp() + ▼ + Fixtures.setUp() + ├── super.setUp() → deploys MockERC20×2, MockAavePool + ├── new YieldSaveVault(...) → vault + ├── usdc.mint(alice, 1M USDC) + ├── alice → usdc.approve(vault, max) + ├── usdc.mint(bob, 1M USDC) + └── bob → usdc.approve(vault, max) + + │ test body + ├── _deposit(alice, 100e6) → vm.prank(alice) + vault.deposit(100e6) + ├── _accrueYield(10e6) → pool.accrueYield(vault, 10e6) + └── _withdraw(alice, shares) → vm.prank(alice) + vault.withdraw(shares) +``` + +--- + +## BaseSepoliaFork — `test/helpers/BaseSepoliaFork.sol` + +### Purpose + +Abstract base for fork-based integration tests. Creates a local fork of Base Sepolia at the current block, wires in the real Aave V3 USDC, aUSDC, and Pool contracts (from env vars or hardcoded defaults), and deploys a fresh `YieldSaveVault` against them. Skips all tests automatically if `BASE_SEPOLIA_RPC_URL` is not set. Provides `_deposit` and `_withdraw` helpers identical in signature to `Fixtures`. + +### State variables + +| Name | Type | Visibility | Value / Description | +|---|---|---|---| +| `USDC_UNIT` | `uint256` | `internal constant` | `1e6` | +| `FEE_RATE_BPS` | `uint256` | `internal constant` | `500` | +| `DEFAULT_BASE_SEPOLIA_USDC` | `address` | `internal constant` | `0xba50Cd2A...` | +| `DEFAULT_BASE_SEPOLIA_AUSDC` | `address` | `internal constant` | `0x10F1A9D1...` | +| `DEFAULT_BASE_SEPOLIA_AAVE_POOL` | `address` | `internal constant` | `0x8bAB6d1b...` | +| `alice` | `address` | `internal` | `makeAddr("alice")` | +| `treasury` | `address` | `internal` | `makeAddr("treasury")` | +| `usdc` | `IERC20` | `internal` | Real USDC on forked chain | +| `aUsdc` | `IERC20` | `internal` | Real aUSDC on forked chain | +| `pool` | `IPool` | `internal` | Real Aave V3 Pool on forked chain | +| `vault` | `YieldSaveVault` | `internal` | Newly deployed vault on forked chain | + +### Constructor / Initializer + +```solidity +function setUp() public virtual { + string memory rpcUrl = vm.envOr("BASE_SEPOLIA_RPC_URL", string("")); + vm.skip(bytes(rpcUrl).length == 0, "BASE_SEPOLIA_RPC_URL is not set"); + + vm.createSelectFork(rpcUrl); + + usdc = IERC20(vm.envOr("BASE_SEPOLIA_USDC", DEFAULT_BASE_SEPOLIA_USDC)); + aUsdc = IERC20(vm.envOr("BASE_SEPOLIA_AUSDC", DEFAULT_BASE_SEPOLIA_AUSDC)); + pool = IPool(vm.envOr("BASE_SEPOLIA_AAVE_POOL", DEFAULT_BASE_SEPOLIA_AAVE_POOL)); + + vault = new YieldSaveVault(address(usdc), address(aUsdc), address(pool), treasury, FEE_RATE_BPS); + + deal(address(usdc), alice, 1_000_000 * USDC_UNIT); + + vm.prank(alice); + usdc.approve(address(vault), type(uint256).max); +} +``` + +`vm.skip` causes all tests in any inheriting contract to be skipped (reported as skipped, not failed) when the RPC URL is absent. This allows `forge test` to succeed on machines without a network connection. + +`deal` uses Foundry's cheatcode to set `alice`'s USDC balance on the forked chain without needing a real USDC faucet. + +### Interaction diagram + +``` + Fork test contract (inherits BaseSepoliaFork) + │ + │ setUp() + ▼ + BaseSepoliaFork.setUp() + │ + ├── vm.envOr("BASE_SEPOLIA_RPC_URL") == "" ? + │ └── vm.skip() → all tests skipped gracefully + │ + ├── vm.createSelectFork(rpcUrl) + │ └── EVM state = Base Sepolia at current head + │ + ├── usdc = 0xba50Cd2A... (real Circle USDC on Base Sepolia) + ├── aUsdc = 0x10F1A9D1... (real Aave aUSDC on Base Sepolia) + ├── pool = 0x8bAB6d1b... (real Aave V3 Pool on Base Sepolia) + │ + ├── new YieldSaveVault(usdc, aUsdc, pool, treasury, 500) + ├── deal(usdc, alice, 1_000_000e6) (Foundry cheatcode) + └── alice → usdc.approve(vault, max) +``` + +--- + +## YieldSaveVaultTest — `test/YieldSaveVaultTest.t.sol` + +### Purpose + +The primary unit test contract. Does not inherit `Fixtures` — it sets up its own fork of Base Sepolia (identical pattern to `BaseSepoliaFork`) with `FEE_RATE_BPS = 1000` (10% rather than 5%). Tests all public and external functions, including constructor validation, deposit/withdraw guards, and all preview view functions. + +### Setup + +Fork-based: reads `BASE_SEPOLIA_RPC_URL`, skips tests if absent. Uses `deal` to give `alice` and `bob` 1,000 USDC each. Approves vault for both. + +**Notable difference from scenario tests:** `FEE_RATE_BPS = 1000` (10%), not 500 (5%). This ensures fee calculations are visibly non-zero and distinct from a 5%-based calculation, making fee-related assertions easier to validate. + +### Test coverage + +| Test | What it validates | +|---|---| +| `test_Constructor_RevertsZeroAddress` | All four address params individually with `address(0)` | +| `test_Constructor_RevertsInvalidFeeRate` | `feeRate = 1001` (one above cap) | +| `test_Deposit_RevertsZeroAmount` | `deposit(0)` → `ZeroAmount` | +| `test_Deposit_Success` | Correct share minting, state updates, event emission | +| `test_Withdraw_RevertsZeroAmount` | `withdraw(0)` → `ZeroAmount` | +| `test_Withdraw_RevertsInsufficientShares` | `withdraw(depositAmount + 1)` → `InsufficientShares` | +| `test_Withdraw_Success` | Full withdraw restores balance (`assertApproxEqAbs` with delta 2 for Aave rounding) | +| `test_ViewFunctions_BeforeDeposit` | `getVaultBalance()`, `getUserBalance()`, `previewDeposit(0)` all return 0 | +| `test_ViewFunctions_AfterDeposit` | `getVaultBalance()`, `getUserBalance()` ≈ deposit amount | +| `test_PreviewWithdraw` | Preview returns ≈ half deposit for half shares | +| `test_PreviewWithdrawFor` | `payout + fee == gross` (identity check) | +| `test_PreviewWithdrawFor_ZeroShares` | Returns `(0, 0, 0)` | +| `test_PreviewWithdrawFor_NonExistentUser` | Returns `payout = 0` for user with no shares | + +### Events declared + +```solidity +event Deposited(address indexed user, uint256 assets, uint256 shares); +event Withdrawn(address indexed user, uint256 shares, uint256 grossAssets, uint256 fee, uint256 payout); +``` + +Re-declared locally so `vm.expectEmit` can reference them. Matches the vault's events exactly. + +### Revert conditions tested + +`ZeroAddress`, `InvalidFeeRate`, `ZeroAmount`, `InsufficientShares`. + +--- + +## DepositScenariosTest — `test/scenarios/Deposit.t.sol` + +### Purpose + +Scenario tests focused exclusively on the `deposit` function and share minting mechanics. Inherits `Fixtures` (mock infrastructure, 5% fee). + +### Test coverage + +| Test | Scenario | +|---|---| +| `test_DepositRevertsOnZeroAmount` | Guard: `ZeroAmount` error on `deposit(0)` | +| `test_FirstDepositMintsSharesOneToOne` | First deposit: `shares == amount`, `previewDeposit` matches | +| `test_SecondDepositUsesCurrentSharePrice` | After 20 USDC yield on 100 USDC deposit, second depositor of 60 USDC gets 50 shares (share price = 1.2) | +| `test_MultipleDepositsAccumulatePrincipalAndShares` | Two deposits by same user: `userDeposits` accumulates correctly, second deposit gets fewer shares due to yield | + +**Key assertion in `test_SecondDepositUsesCurrentSharePrice`:** +``` +totalAssets = 120e6, totalShares = 100e6 +bob deposits 60e6 USDC +shares = 60e6 * 100e6 / 120e6 = 50e6 +``` + +--- + +## WithdrawScenariosTest — `test/scenarios/Withdraw.t.sol` + +### Purpose + +Scenario tests for the `withdraw` function — withdrawal guards, fee deduction, and proportional principal reduction. Inherits `Fixtures`. + +### Test coverage + +| Test | Scenario | +|---|---| +| `test_WithdrawRevertsOnZeroShares` | Guard: `ZeroAmount` on `withdraw(0)` | +| `test_WithdrawRevertsWhenUserLacksShares` | Guard: `InsufficientShares` — bob withdraws 1 share without depositing | +| `test_FullWithdrawalReturnsPrincipalPlusNetYield` | 100 USDC deposit + 10 USDC yield → payout = 109.5 USDC (10 × 5% fee = 0.5) | +| `test_PartialWithdrawalReducesPrincipalProportionally` | 40% withdrawal: payout = 47.6 USDC; remaining `userDeposits` = 60 USDC; `getUserBalance` = 71.4 USDC | + +**Key numbers in `test_FullWithdrawalReturnsPrincipalPlusNetYield`:** +``` +grossAssets = 110e6, principal = 100e6, yield = 10e6 +fee = 10e6 * 500 / 10000 = 500_000 +payout = 110e6 - 500_000 = 109_500_000 +``` + +**Key numbers in `test_PartialWithdrawalReducesPrincipalProportionally`:** +``` +After 20 USDC yield: totalAssets = 120e6, totalShares = 100e6 +Redeem 40e6 shares (40%): + grossAssets = 40e6 * 120e6 / 100e6 = 48e6 + principal = 100e6 * 40e6 / 100e6 = 40e6 + yield = 48e6 - 40e6 = 8e6 + fee = 8e6 * 500 / 10000 = 400_000 + payout = 48e6 - 400_000 = 47_600_000 +Remaining: userShares = 60e6, userDeposits = 60e6 +Remaining getUserBalance = 60e6 shares * 72e6 remaining assets / 60e6 remaining shares + grossAssets = 72e6, yield = 72e6 - 60e6 = 12e6 + fee = 12e6 * 500 / 10000 = 600_000 + payout = 72e6 - 600_000 = 71_400_000 +``` + +--- + +## FeeScenariosTest — `test/scenarios/Fee.t.sol` + +### Purpose + +Tests the fee model in isolation — confirming that fees apply only to yield, that zero yield produces zero fee, and that `previewWithdraw` matches the actual deduction. Inherits `Fixtures`. + +### Test coverage + +| Test | Scenario | +|---|---| +| `test_FeeOnlyAppliesToYield` | 500 USDC deposit + 21 USDC yield; payout = 519.95 USDC; treasury receives 1.05 USDC (5% of 21) | +| `test_ZeroYieldChargesZeroFee` | Immediate withdrawal after deposit: payout = principal exactly, treasury receives 0 | +| `test_PreviewWithdrawMatchesFeeDeduction` | 200 USDC + 20 USDC yield → preview = 219 USDC (20 × 5% = 1 USDC fee) | + +**Key assertion in `test_FeeOnlyAppliesToYield`:** +``` +deposit 500e6, yield 21e6 → totalAssets = 521e6 +fee = 21e6 * 500 / 10000 = 1_050_000 +payout = 521e6 - 1_050_000 = 519_950_000 +alice USDC = 1_000_000e6 (start) - 500e6 (deposit) + 519_950_000 = 1_000_019_950_000 +treasury receives 1_050_000 +``` + +--- + +## ShareMathScenariosTest — `test/scenarios/ShareMath.t.sol` + +### Purpose + +Tests the share price appreciation mechanism and its effects on depositors who join at different times. Confirms that `getUserBalance` correctly reflects net yield after fee. Inherits `Fixtures`. + +### Test coverage + +| Test | Scenario | +|---|---| +| `test_SharePriceAppreciatesAsYieldAccrues` | After 50 USDC yield on 1000 USDC: `aUsdc.balanceOf(vault) == 1050e6`, `totalShares == 1000e6` (share price 1.05) | +| `test_LaterDepositorGetsFewerSharesAfterYield` | After 50% yield: bob's 75 USDC deposit gets 50 shares (75 / 1.5 = 50) | +| `test_GetUserBalanceReturnsNetAssetsAfterFee` | 250 USDC + 25 USDC yield → `getUserBalance` = 273.75 USDC (25 × 5% = 1.25 fee) | + +--- + +## BaseSepoliaIntegrationTest — `test/fork/BaseSepoliaIntegration.t.sol` + +### Purpose + +Integration tests that run against real Aave V3 contracts on a live Base Sepolia fork. They validate that the vault's `IPool` calls work correctly with the real Aave implementation — including aUSDC minting, USDC withdrawal, and balance tracking. Inherits `BaseSepoliaFork`. + +### Test coverage + +| Test | What it validates | +|---|---| +| `test_DepositSuppliesRealUsdcToAave` | After `deposit(100 USDC)`: USDC left vault, aUSDC arrived at vault, shares and principal recorded correctly | +| `test_WithdrawRedeemsPrincipalFromRealAavePool` | `previewWithdraw ≈ depositAmount`, payout ≈ depositAmount, all state zeroed out | +| `test_GetVaultBalanceTracksRealATokenBalance` | `getVaultBalance() ≈ aUsdc.balanceOf(vault) ≈ depositAmount` | + +**Tolerance:** All assertions use `assertApproxEqAbs(value, expected, 2)`. The tolerance of 2 (0.000002 USDC) accounts for: +- Aave's internal rounding when converting USDC to aUSDC +- Any interest accrued between the transaction and the assertion (on a live fork, time can pass) + +### Interaction diagram + +``` + BaseSepoliaIntegrationTest + │ + │ setUp() → BaseSepoliaFork.setUp() + │ └── fork Base Sepolia + │ └── new YieldSaveVault (real Aave addresses) + │ └── deal(usdc, alice, 1M USDC) + │ + │ test_DepositSuppliesRealUsdcToAave() + │ ├── _deposit(alice, 100e6) + │ │ vault.deposit(100e6) + │ │ └── real USDC.transferFrom(alice → vault) + │ │ └── real Aave.supply(usdc, 100e6, vault, 0) + │ │ └── real aUSDC minted to vault + │ └── assert aUsdc.balanceOf(vault) ≈ 100e6 +``` + +--- + +## Deploy — `script/Deploy.s.sol` + +### Purpose + +Foundry deployment script. Reads network configuration from environment variables, detects the target chain from `block.chainid`, deploys `YieldSaveVault` with the correct Aave addresses, and writes the deployment record to `deployments/{network}.json`. + +### State variables + +None. All values are read from environment or computed at run time. + +### Constructor + +None. Inherits `Script` from forge-std. + +### Functions + +#### `run() → YieldSaveVault vault` + +The entry point called by `forge script`. + +**Logic flow:** + +``` +run() + │ + ├── deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY") + ├── treasury = vm.envAddress("TREASURY") + ├── feeRate = vm.envOr("FEE_RATE_BPS", 500) + │ + ├── (usdc, aUsdc, pool, network) = _loadNetworkConfig(block.chainid) + │ + ├── vm.startBroadcast(deployerPrivateKey) + │ vault = new YieldSaveVault(usdc, aUsdc, pool, treasury, feeRate) + │ vm.stopBroadcast() + │ + ├── _writeDeployment(network, address(vault)) + │ path = "./deployments/{network}.json" + │ vm.serializeAddress / vm.serializeUint / vm.writeJson + │ + └── console2.log("YieldSaveVault deployed to", address(vault)) + return vault +``` + +#### `_loadNetworkConfig(uint256 chainId) → (address usdc, address aUsdc, address pool, string network)` + +``` +if chainId == 11155111 (Sepolia): + return (env SEPOLIA_USDC, env SEPOLIA_AUSDC, env SEPOLIA_AAVE_POOL, "sepolia") + +if chainId == 84532 (Base Sepolia): + return (env BASE_SEPOLIA_USDC, env BASE_SEPOLIA_AUSDC, env BASE_SEPOLIA_AAVE_POOL, "base-sepolia") + +else: revert("unsupported chain") +``` + +#### `_writeDeployment(string network, address vault)` + +Writes a JSON file to `./deployments/{network}.json` using Foundry's `vm.serializeAddress`, `vm.serializeUint`, and `vm.writeJson` cheatcodes. The file contains `vault`, `chainId`, and `block` fields. + +### Access restrictions + +`vm.startBroadcast` signs transactions with `DEPLOYER_PRIVATE_KEY`. Only the address corresponding to that key pays gas and deploys the contract. The script itself has no on-chain access control. + +### Events + +None emitted by the script. `YieldSaveVault`'s constructor does not emit events. Deployment is confirmed via `console2.log` output. + +### Revert conditions + +| Condition | Cause | +|---|---| +| `DEPLOYER_PRIVATE_KEY` not set | `vm.envUint` reverts: environment variable not found | +| `TREASURY` not set | `vm.envAddress` reverts | +| Aave address env vars not set | `vm.envAddress` reverts inside `_loadNetworkConfig` | +| Unsupported `chainId` | `revert("unsupported chain")` | +| Any `address(0)` in Aave addresses | `YieldSaveVault` constructor reverts `ZeroAddress` | +| `feeRate > 1000` | `YieldSaveVault` constructor reverts `InvalidFeeRate` | +| Insufficient gas in deployer wallet | EVM out-of-gas | + +### Interaction diagram + +``` + forge script Deploy.s.sol + │ + │ run() + ▼ + Deploy script + │ + ├── read env vars (vm.envUint, vm.envAddress, vm.envOr) + │ + ├── _loadNetworkConfig(block.chainid) + │ ├── chainId == 11155111 → read SEPOLIA_* env vars + │ └── chainId == 84532 → read BASE_SEPOLIA_* env vars + │ + ├── vm.startBroadcast(privateKey) + │ └── new YieldSaveVault(usdc, aUsdc, pool, treasury, feeRate) + │ └── [on-chain deployment transaction] + │ vm.stopBroadcast() + │ + └── _writeDeployment(network, vaultAddress) + └── vm.writeJson → deployments/{network}.json +``` + +--- + +## VerifyAddresses — `script/VerifyAddresses.s.sol` + +### Purpose + +A read-only utility script that logs the Aave V3 contract addresses from environment variables for the current network. Used before deployment to verify that the correct Aave addresses are configured for the target chain. Takes no action on-chain. + +### State variables + +None. + +### Functions + +#### `run() external view` + +``` +if block.chainid == 11155111: + log "Network: Sepolia" + log SEPOLIA_USDC + log SEPOLIA_AUSDC + log SEPOLIA_AAVE_POOL + +if block.chainid == 84532: + log "Network: Base Sepolia" + log BASE_SEPOLIA_USDC + log BASE_SEPOLIA_AUSDC + log BASE_SEPOLIA_AAVE_POOL + +else: revert("unsupported chain") +``` + +### Access restrictions + +View-only (`external view`). No transactions broadcast. + +### Events + +None. + +### Revert conditions + +| Condition | Cause | +|---|---| +| Unsupported chain | `revert("unsupported chain")` | +| Address env vars not set | `vm.envAddress` reverts | + +### Usage + +```bash +# Verify Sepolia addresses before deployment +forge script script/VerifyAddresses.s.sol --rpc-url $SEPOLIA_RPC_URL + +# Verify Base Sepolia addresses +forge script script/VerifyAddresses.s.sol --rpc-url $BASE_SEPOLIA_RPC_URL +``` + +### Interaction diagram + +``` + forge script VerifyAddresses.s.sol --rpc-url + │ + │ run() (read-only: no broadcast, no state change) + ▼ + VerifyAddresses + │ + ├── read block.chainid from RPC + ├── read env vars (vm.envAddress) + └── console2.log(addresses) + └── printed to terminal +``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..425c104 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,223 @@ +# Deployment Guide + +## Overview + +Deployment is handled by `script/Deploy.s.sol`. The script: +1. Reads configuration from environment variables +2. Detects the target network from `block.chainid` +3. Deploys `YieldSaveVault` with the correct Aave addresses for that network +4. Writes a deployment record to `deployments/{network}.json` + +All deployment commands are wrapped in the `Makefile`. The `NETWORK` variable selects the target. + +--- + +## Pre-Deployment Checklist + +Before deploying to any network: + +- [ ] `.env` is populated with all required variables for the target network +- [ ] `TREASURY` is set to a verified, controlled address (fees flow here permanently — it cannot be changed after deployment) +- [ ] `FEE_RATE_BPS` is confirmed (default `500` = 5%; max `1000` = 10%) +- [ ] Aave V3 addresses for the target network are correct (USDC, aUSDC, Pool) +- [ ] Deployer wallet has sufficient gas +- [ ] `forge build` passes cleanly +- [ ] `forge test` passes cleanly + +--- + +## Local Deployment (Anvil) + +Use Anvil for development and manual testing. + +```bash +# Terminal 1 — start the local node +make anvil + +# Terminal 2 — deploy +make deploy NETWORK=anvil +``` + +Anvil uses `PRIVATE_KEY` from `.env`. Any of Anvil's default private keys work. + +After deployment, the vault address is printed to stdout and written to `deployments/anvil.json` (this file is gitignored). + +--- + +## Sepolia Deployment + +```bash +make deploy NETWORK=sepolia +``` + +**Required `.env` variables:** + +```bash +DEPLOYER_PRIVATE_KEY=0x... # Must hold Sepolia ETH for gas +TREASURY=0x... +SEPOLIA_RPC_URL=https://... +ETHERSCAN_API_KEY=... +SEPOLIA_USDC=0x... +SEPOLIA_AUSDC=0x... +SEPOLIA_AAVE_POOL=0x... +FEE_RATE_BPS=500 +``` + +The `--verify` flag is included automatically. Etherscan verification happens as part of the same command. If verification fails (e.g., rate limiting), re-run manually: + +```bash +make verify NETWORK=sepolia ADDRESS=0xYourVaultAddress +``` + +--- + +## Base Sepolia Deployment + +```bash +make deploy NETWORK=base-sepolia +``` + +**Required `.env` variables:** + +```bash +DEPLOYER_PRIVATE_KEY=0x... +TREASURY=0x... +BASE_SEPOLIA_RPC_URL=https://... +ETHERSCAN_API_KEY=... # Used as the Blockscout API key +BASE_SEPOLIA_USDC=0x... +BASE_SEPOLIA_AUSDC=0x... +BASE_SEPOLIA_AAVE_POOL=0x... +FEE_RATE_BPS=500 +``` + +Verification uses Blockscout (`--verifier blockscout --verifier-url https://base-sepolia.blockscout.com/api/`). If it fails: + +```bash +make verify NETWORK=base-sepolia ADDRESS=0xYourVaultAddress +``` + +--- + +## Mainnet Deployment + +> **Warning:** Mainnet deployment is irreversible. All parameters are immutable. Double-check everything. + +```bash +make deploy NETWORK=mainnet +``` + +The Makefile requires `MAINNET_RPC_URL` in `.env`. No mainnet Aave address env vars are pre-configured — add them before deploying: + +```bash +MAINNET_USDC=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +MAINNET_AUSDC=0x98C23E9d8f34FEFb1B7BD6a91B7CF122b3EB2110 +MAINNET_AAVE_POOL=0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 +``` + +Then update `_loadNetworkConfig` in `script/Deploy.s.sol` to handle `chainId == 1` and read these addresses. + +**Pre-mainnet checklist (additional):** +- [ ] Independent security audit completed +- [ ] Deploy to testnet first and verify with fork tests +- [ ] Treasury address is a multisig, not an EOA +- [ ] Deployment dry-run with `--dry-run` flag reviewed +- [ ] Team review of final deployment transaction + +--- + +## Adding a New Network + +1. **Add Aave addresses to `.env.example`** for the new network: + +```bash +NEWNET_USDC= +NEWNET_AUSDC= +NEWNET_AAVE_POOL= +NEWNET_RPC_URL= +``` + +2. **Add a `chainId` branch to `_loadNetworkConfig`** in `script/Deploy.s.sol`: + +```solidity +if (chainId == 99999) { + return ( + vm.envAddress("NEWNET_USDC"), + vm.envAddress("NEWNET_AUSDC"), + vm.envAddress("NEWNET_AAVE_POOL"), + "newnet" + ); +} +``` + +3. **Add a `deployments/newnet.json` placeholder**: + +```json +{} +``` + +4. **Add a Makefile target** for deploy and verify with the correct RPC and verifier flags. + +5. **Add an RPC endpoint** to `foundry.toml`: + +```toml +[rpc_endpoints] +newnet = "${NEWNET_RPC_URL}" +``` + +--- + +## Deployment Records + +After a successful deployment, `Deploy.s.sol` writes a JSON file to `deployments/`: + +```json +{ + "vault": "0xC0aAd48188dabF8d5B33e30A0946d79d5C8F6323", + "chainId": 84532, + "block": 40872728 +} +``` + +These files are committed to the repository. The frontend reads them to resolve the vault address per network. + +The `deployments/` directory has read-write filesystem access granted in `foundry.toml`: + +```toml +fs_permissions = [{ access = "read-write", path = "./deployments" }] +``` + +--- + +## Post-Deployment Checklist + +After deploying to any network: + +- [ ] Vault address in `deployments/{network}.json` matches the on-chain deployment +- [ ] Contract is verified on the block explorer (source code visible) +- [ ] `getVaultBalance()` returns 0 (empty vault, correct state) +- [ ] Run a test deposit via `cast` or the frontend to confirm basic operation +- [ ] Confirm aUSDC balance of vault matches deposit amount after one block +- [ ] Confirm withdrawal returns correct payout +- [ ] Treasury address confirmed via `vault.treasury()` read + +```bash +# Verify deployed parameters +cast call $VAULT "usdc()(address)" --rpc-url $RPC +cast call $VAULT "aUsdc()(address)" --rpc-url $RPC +cast call $VAULT "aavePool()(address)" --rpc-url $RPC +cast call $VAULT "treasury()(address)" --rpc-url $RPC +cast call $VAULT "feeRate()(uint256)" --rpc-url $RPC +``` + +--- + +## Verifying Aave Addresses + +Before deploying, confirm Aave contract addresses are correct for the target network: + +```bash +# Run the address verification script +forge script script/VerifyAddresses.s.sol --rpc-url $TARGET_RPC_URL +``` + +This logs the USDC, aUSDC, and Pool addresses loaded from your `.env`. Cross-check them against the [Aave V3 address book](https://github.com/bgd-labs/aave-address-book). diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..7bb9e42 --- /dev/null +++ b/docs/developer-guide.md @@ -0,0 +1,68 @@ +# Developer Guide + +This page is the starting point for engineers working on this codebase. It links to the topic-specific docs below. + +## Orientation + +YieldSave is a single Solidity contract (`YieldSaveVault`) that accepts USDC deposits, supplies them to Aave V3, tracks shares per user, and distributes yield implicitly via share price appreciation. The codebase is pure Foundry — no Node.js tooling is involved. + +Read these first: + +| Document | What it covers | +|---|---| +| [Architecture](architecture.md) | Contract design, share model math, fee model, invariants, security model | +| [Setup](setup.md) | Prerequisites, installation, environment variables, local Anvil workflow | +| [Testing](testing.md) | Test suite structure, running tests, writing new tests, mock setup | +| [Deployment](deployment.md) | Deploying to each network, verification, post-deploy checklist | +| [Contract Reference](contracts.md) | Per-contract: purpose, state, functions, logic flow, events, reverts, interaction diagrams | +| [Reference](reference.md) | Full ABI, function signatures, events, errors, `cast` one-liners | +| [Troubleshooting](troubleshooting.md) | Common errors and how to fix them | +| [FAQ](faq.md) | Frequently asked developer questions | + +## Common Workflows + +### Run the test suite + +```bash +forge test # all unit + scenario tests (no RPC needed) +forge test -vvvv # with full call traces +make fork-base # fork tests against real Aave V3 (requires BASE_SEPOLIA_RPC_URL) +``` + +### Start local development + +```bash +# Terminal 1 +anvil + +# Terminal 2 +make deploy NETWORK=anvil +``` + +### Format and check + +```bash +make format # format Solidity +forge test # confirm tests still pass +``` + +### Deploy to testnet + +```bash +# 1. Fill in .env (see docs/setup.md for required variables) +# 2. Run +make deploy NETWORK=base-sepolia +``` + +## Code Conventions + +| Element | Convention | Example | +|---|---|---| +| Internal functions | `_camelCase` prefix | `_totalAssets()`, `_safeTransfer()` | +| Public/external functions | `camelCase` | `deposit()`, `getUserBalance()` | +| Constants | `SCREAMING_SNAKE_CASE` | `BPS_DENOMINATOR`, `MAX_FEE_BPS` | +| Custom errors | `PascalCase` | `ZeroAmount`, `InsufficientShares` | +| Events | `PascalCase` | `Deposited`, `Withdrawn` | +| Test functions | `test_PascalCaseDescription` | `test_FeeOnlyAppliesToYield` | + +Comments explain *why*, not *what*. Solidity version is pinned at `0.8.30` — do not widen the pragma. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..f61ae4a --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,178 @@ +# Developer FAQ + +## General + +**Q: Is this ERC-4626 compliant?** + +No. The vault uses ERC-4626 concepts (shares, `deposit`, `withdraw`, `previewDeposit`) but shares are not transferable ERC-20 tokens. ERC-4626 requires shares to implement the full ERC-20 interface so they can be traded, used as collateral, or composed with other protocols. That is deferred to a future version to keep the MVP's audit surface minimal. See [Architecture — Design Decisions](architecture.md#design-decisions). + +--- + +**Q: Can the fee rate be changed after deployment?** + +No. `feeRate` is an `immutable` state variable — it is set once in the constructor and cannot be changed. To change the fee rate, a new vault must be deployed with the updated rate and users must migrate. + +--- + +**Q: Can the treasury address be changed?** + +No, for the same reason. `treasury` is immutable. Any fee recipient change requires a re-deployment. + +--- + +**Q: Is there an admin or owner role?** + +No. There are no privileged roles. The deployer has no special access after deployment. No function exists to pause the vault, adjust parameters, or withdraw user funds. See [Architecture — Security Model](architecture.md#security-model). + +--- + +**Q: Can the contract be upgraded?** + +No. There is no proxy, no `delegatecall`, and no upgrade mechanism. Bug fixes require deploying a new contract and migrating users. See [Maintenance — Re-Deployment and Migration](maintenance.md#re-deployment-and-migration). + +--- + +## Share Model + +**Q: Why do shares have the same decimals as USDC (6) on first deposit?** + +The first deposit uses a 1:1 ratio: `shares = amount`. USDC has 6 decimals, so the first depositor of `1_000_000` (1 USDC) gets `1_000_000` shares. Later deposits use the share price formula, which preserves this scale as long as yield accrual is gradual. + +--- + +**Q: What happens if the vault has assets but no shares (orphaned yield)?** + +This cannot happen through normal usage. `totalShares` and `totalAssets` only change together — deposits add both, withdrawals reduce both. The only way to get assets with zero shares is via a direct aUSDC transfer to the vault address (which is extremely unlikely and would result in that yield being unreachable). + +--- + +**Q: Can share price decrease?** + +Only if Aave suffers a loss event (e.g. bad debt from a security exploit) that reduces the aUSDC balance of the vault. In normal operation, share price is monotonically non-decreasing. + +--- + +**Q: What does `userDeposits` track exactly?** + +It tracks the user's cumulative principal — the total USDC deposited, adjusted downward proportionally each time the user makes a partial withdrawal. It is not the current USDC value of their shares. It is used solely to calculate the fee: fee = `feeRate × max(0, currentValue − principal)`. + +--- + +## Fees + +**Q: When is the fee charged?** + +Only at withdrawal, and only on the yield portion. A user who deposits and immediately withdraws (no time for yield to accrue) pays zero fee. The fee is deducted from the payout before it is sent to the user. + +**Q: What if my withdrawal payout would be less than my deposit due to rounding?** + +The fee calculation clamps yield to `max(0, grossAssets − principal)`. If rounding causes `grossAssets < principal`, yield is treated as zero and no fee is charged. The user receives `grossAssets` (which equals `grossAssets − 0`). This means the protocol absorbs rounding errors rather than the user. + +--- + +**Q: How do I calculate the exact fee before withdrawing?** + +Use `previewWithdrawFor`: + +```solidity +(uint256 payout, uint256 grossAssets, uint256 fee) = + vault.previewWithdrawFor(userAddress, sharesToRedeem); +``` + +Or via `cast`: + +```bash +cast call $VAULT "previewWithdrawFor(address,uint256)(uint256,uint256,uint256)" \ + $USER $SHARES --rpc-url $RPC +``` + +--- + +## Integration + +**Q: USDC has 6 decimals — what unit should I use for amounts?** + +Always pass amounts in the smallest unit (i.e. `1 USDC = 1_000_000`). Use `parseUnits(amount, 6)` in JavaScript/TypeScript and `formatUnits(amount, 6)` when displaying. + +```typescript +import { parseUnits, formatUnits } from "viem"; + +const depositAmount = parseUnits("100", 6); // 100 USDC → 100_000_000n +const display = formatUnits(rawBalance, 6); // 100_000_000n → "100" +``` + +--- + +**Q: Should I use `previewDeposit` / `previewWithdraw` for UI display?** + +Yes — they are view functions with no gas cost. Call them to show the user what they will receive before they sign the transaction. + +Note: `previewWithdraw` is relative to `msg.sender`. If you need to preview for a different address, use `previewWithdrawFor(address, shares)` instead. + +--- + +**Q: How do I listen for deposit and withdrawal events?** + +Subscribe to the `Deposited` and `Withdrawn` events filtered by the user's address: + +```typescript +const depositLogs = await client.getLogs({ + address: VAULT_ADDRESS, + event: parseAbiItem("event Deposited(address indexed user, uint256 assets, uint256 shares)"), + args: { user: userAddress }, + fromBlock: DEPLOY_BLOCK, +}); +``` + +The `Withdrawn` event includes `shares`, `grossAssets`, `fee`, and `payout` — enough to show a full breakdown in the UI. + +--- + +**Q: Where do I get the deployed ABI?** + +After `forge build`, the full ABI is at `out/YieldSaveVault.sol/YieldSaveVault.json`. The relevant subset for frontend use is documented in [Reference — ABI (JSON)](reference.md#abi-json). + +--- + +## Testing + +**Q: Do I need an RPC to run tests?** + +No. The standard test suite (`test/scenarios/`, `test/YieldSaveVault.t.sol`) uses mock contracts and runs entirely in-process. Only `test/fork/` requires `BASE_SEPOLIA_RPC_URL`. See [Testing Guide](testing.md). + +--- + +**Q: How do I simulate yield accrual in tests?** + +Call `_accrueYield(amount)` in your test (available via `Fixtures`). Under the hood it calls `mockPool.accrueYield(amount)`, which mints aUSDC directly to the vault — the same effect as Aave paying interest over time, but instantaneous. + +```solidity +_deposit(alice, 1_000e6); +_accrueYield(50e6); // vault earned 50 USDC of yield +uint256 balance = vault.getUserBalance(alice); +// balance ≈ 1047.5e6 (1000 principal + 50 yield - 5% fee on yield) +``` + +--- + +**Q: How do I test the contract with a real Aave pool locally?** + +Use Foundry's fork mode to create a local EVM clone of Base Sepolia: + +```bash +forge test --fork-url $BASE_SEPOLIA_RPC_URL --match-path test/fork/ +``` + +The fork is a full snapshot — you get real Aave contracts, real USDC, and real aUSDC. The `BaseSepoliaFork` helper in `test/helpers/` handles setup. + +--- + +**Q: Why is my fuzz test failing with values I didn't expect?** + +Fuzz tests receive random inputs including edge cases like `0`, `type(uint256).max`, and values that overflow intermediate calculations. Use `bound(value, min, max)` to constrain inputs to a safe range: + +```solidity +amount = bound(amount, 1e6, 1_000_000e6); +``` + +See [Testing Guide — Fuzz Tests](testing.md#fuzz-test) for a full example. diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..69f4a4c --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,210 @@ +# Maintenance Guide + +## Overview + +`YieldSaveVault` is an immutable contract. Once deployed: +- No parameters can be changed +- No funds can be moved by an admin +- No logic can be upgraded + +"Maintenance" therefore means: monitoring vault health, collecting fees, responding to external incidents (Aave, USDC), and re-deploying when a bug fix or upgrade is needed. + +--- + +## Vault Health Indicators + +Check these regularly to confirm the vault is operating correctly. + +### 1. Total assets tracking + +The vault's aUSDC balance should equal or exceed the sum of all user deposits (difference is yield): + +```bash +cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC +``` + +Expected: value grows every block as Aave accrues interest. A sudden drop to zero is a critical incident. + +### 2. Share price + +Share price should be monotonically non-decreasing: + +``` +sharePrice = getVaultBalance() / totalShares +``` + +```bash +ASSETS=$(cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC | cast to-dec) +SHARES=$(cast call $VAULT "totalShares()(uint256)" --rpc-url $RPC | cast to-dec) +echo "Share price: $ASSETS / $SHARES" +``` + +A declining share price indicates a loss event in Aave (a serious incident requiring investigation). + +### 3. Aave withdrawal availability + +Aave withdrawals fail when the pool utilisation is 100% (all USDC lent out). Check the available liquidity: + +```bash +# aUSDC balance held by Aave Pool == withdrawable USDC +cast call $AUSDC "balanceOf(address)(uint256)" $AAVE_POOL --rpc-url $RPC +``` + +If this is low relative to `getVaultBalance()`, withdrawals may start reverting. + +### 4. Failed transactions + +Monitor the block explorer for failed transactions to the vault address. Frequent reverts on `withdraw` may indicate Aave liquidity constraints. + +--- + +## Fee Collection + +Fees are sent to `treasury` at the time of each withdrawal. There is no fee accumulation inside the vault — each `Withdrawn` event records the fee paid: + +```solidity +event Withdrawn(address indexed user, uint256 shares, uint256 grossAssets, uint256 fee, uint256 payout); +``` + +To calculate total fees collected on a network, query historical `Withdrawn` events and sum the `fee` field: + +```bash +cast logs --address $VAULT \ + --event "Withdrawn(address,uint256,uint256,uint256,uint256)" \ + --from-block $DEPLOY_BLOCK \ + --rpc-url $RPC +``` + +Or use the block explorer's "Events" tab filtered to the `Withdrawn` event signature. + +The treasury address cannot be changed after deployment. If the treasury needs to change, a re-deployment is required. + +--- + +## Incident Response + +### Aave liquidity crunch + +**Symptom:** User `withdraw` calls revert with a low-level error from `aavePool.withdraw`. + +**Cause:** Aave's pool utilisation is near 100% — all USDC is currently borrowed. + +**Response:** +1. Check Aave's utilisation rate on their dashboard +2. Inform users that withdrawals are temporarily unavailable +3. Monitor until utilisation drops (borrowers repay or more suppliers enter) +4. No contract action is needed — withdrawals automatically succeed once liquidity returns + +**This is not a vault bug.** It is an expected condition under extreme Aave usage. + +### Aave V3 security incident + +**Symptom:** Aave announces a vulnerability, pauses the protocol, or funds move unexpectedly. + +**Response:** +1. Assess whether Aave has paused the relevant pool — if so, deposits and withdrawals via the vault are also paused (they revert) +2. Monitor Aave governance and security disclosures +3. If funds are at risk, Aave's emergency mechanism may freeze the pool +4. Communicate status to users immediately +5. If Aave migrates to a new pool contract, a vault re-deployment is required pointing to the new pool address + +The vault cannot be patched in-place. A migration plan (see below) is the only remediation path if the Aave integration is permanently broken. + +### USDC depeg or Circle issue + +**Symptom:** USDC trades significantly below $1.00, or Circle freezes transfers. + +**Response:** +1. If Circle blacklists the vault address, all deposits and withdrawals fail permanently — a new vault contract would need to be deployed at a different address +2. If USDC depegs, user funds are still denominated in USDC — the vault has no USD guarantee, only USDC +3. Communicate the risk to users and refer to Circle's official announcements + +### Bug in YieldSaveVault + +**Symptom:** A logic error is found in the contract. + +**Response:** +1. Assess whether the bug is exploitable in its current state +2. If actively being exploited: contact Aave to pause the pool if necessary; communicate to users to withdraw immediately +3. Deploy a fixed contract (see re-deployment below) +4. Communicate the issue transparently, including the vulnerability and timeline + +--- + +## Re-Deployment and Migration + +Because `YieldSaveVault` is immutable, any bug fix, parameter change, or feature upgrade requires deploying a new contract and migrating user funds. + +### Steps for re-deployment + +1. **Deploy the new contract** following the [Deployment Guide](deployment.md) +2. **Announce migration** to users with a clear deadline — e.g., "withdraw from old vault before DATE" +3. **Update the frontend** to point to the new vault address (in `deployments/{network}.json`) +4. **Do not destroy the old vault** — users who miss the deadline can still withdraw directly on-chain via the block explorer + +### Assisted migration + +If the situation requires assisting users in moving funds (e.g., the old vault has a bug that prevents withdrawal but funds are recoverable): + +- This must be done entirely via on-chain user-initiated transactions +- The vault has no admin function to move user funds on their behalf +- Communicate exact steps for users to call `withdraw` with their share balance + +### Updating the deployment record + +After deploying a replacement, update `deployments/{network}.json` with the new address and commit it: + +```json +{ + "vault": "0xNewVaultAddress", + "chainId": 84532, + "block": 99999999, + "previous": "0xOldVaultAddress" +} +``` + +Adding `previous` preserves the audit trail. + +--- + +## Routine Operations + +### Updating Aave addresses for a new pool version + +If Aave V3 migrates to a new pool contract: +1. Obtain the new `Pool`, `USDC`, and `aUSDC` addresses from the Aave address book +2. Update `.env` with the new addresses +3. Deploy a new vault pointing to the new addresses +4. Migrate users as above + +### Checking deployment integrity + +Periodically verify that immutable parameters on deployed vaults match expected values: + +```bash +VAULT=0x... +RPC=... + +echo "USDC: $(cast call $VAULT 'usdc()(address)' --rpc-url $RPC)" +echo "aUSDC: $(cast call $VAULT 'aUsdc()(address)' --rpc-url $RPC)" +echo "Pool: $(cast call $VAULT 'aavePool()(address)' --rpc-url $RPC)" +echo "Treasury: $(cast call $VAULT 'treasury()(address)' --rpc-url $RPC)" +echo "Fee BPS: $(cast call $VAULT 'feeRate()(uint256)' --rpc-url $RPC)" +``` + +Compare against `deployments/{network}.json` and the known Aave addresses. + +--- + +## Known Limitations + +| Limitation | Implication | +|---|---| +| Immutable `treasury` | Fee recipient cannot be changed without re-deployment | +| Immutable `feeRate` | Fee rate cannot be adjusted without re-deployment | +| No emergency pause | Cannot halt deposits/withdrawals (must rely on Aave's pause if Aave is the issue) | +| No share transferability | User positions cannot be transferred or used as collateral | +| Single asset (USDC) | Adding new deposit assets requires a new vault contract | +| No yield strategy selection | Yield source is fixed to Aave V3 at deployment | + +These are deliberate simplifications for the MVP. They can be addressed in future versions through new deployments. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..addef4a --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,439 @@ +# Contract Reference + +## YieldSaveVault + +**Source:** [src/YieldSaveVault.sol](../src/YieldSaveVault.sol) +**Compiler:** Solidity 0.8.30 +**License:** MIT + +### Deployed Addresses + +| Network | Chain ID | Address | +|---|---|---| +| Sepolia | 11155111 | `0x6C2Df464b38e92Ec8d01f8BEaF621f1ad894C107` | +| Base Sepolia | 84532 | `0xC0aAd48188dabF8d5B33e30A0946d79d5C8F6323` | + +--- + +## Constructor + +```solidity +constructor( + address usdc_, + address aUsdc_, + address aavePool_, + address treasury_, + uint256 feeRate_ +) +``` + +All parameters are validated at construction and stored as immutables — they cannot change after deployment. + +| Parameter | Type | Validation | Description | +|---|---|---|---| +| `usdc_` | `address` | `!= address(0)` | The ERC-20 deposit/withdrawal token (USDC) | +| `aUsdc_` | `address` | `!= address(0)` | Aave's interest-bearing wrapper token (aUSDC) | +| `aavePool_` | `address` | `!= address(0)` | Aave V3 Pool contract | +| `treasury_` | `address` | `!= address(0)` | Recipient of protocol fees | +| `feeRate_` | `uint256` | `<= MAX_FEE_BPS` | Fee rate in basis points (e.g. `500` = 5%) | + +--- + +## Constants + +```solidity +uint256 public constant BPS_DENOMINATOR = 10_000; +uint256 public constant MAX_FEE_BPS = 1_000; +``` + +| Name | Value | Description | +|---|---|---| +| `BPS_DENOMINATOR` | `10_000` | Denominator for basis point calculations | +| `MAX_FEE_BPS` | `1_000` | Maximum fee rate allowed at construction (10%) | + +--- + +## Immutable State + +```solidity +IERC20 public immutable usdc; +IERC20 public immutable aUsdc; +IPool public immutable aavePool; +address public immutable treasury; +uint256 public immutable feeRate; +``` + +| Variable | Type | Description | +|---|---|---| +| `usdc` | `IERC20` | The deposit token | +| `aUsdc` | `IERC20` | Aave's aUSDC (yield-bearing wrapper) | +| `aavePool` | `IPool` | Aave V3 Pool | +| `treasury` | `address` | Fee recipient | +| `feeRate` | `uint256` | Protocol fee rate in basis points | + +--- + +## Mutable State + +```solidity +uint256 public totalShares; +mapping(address => uint256) public userShares; +mapping(address => uint256) public userDeposits; +``` + +| Variable | Type | Description | +|---|---|---| +| `totalShares` | `uint256` | Sum of all shares across all users | +| `userShares` | `mapping(address => uint256)` | Share balance per user | +| `userDeposits` | `mapping(address => uint256)` | Original principal deposited per user (in USDC, 6 decimals) | + +`totalAssets` is **not** stored — it is always read live from `aUsdc.balanceOf(address(this))`. + +--- + +## Write Functions + +### deposit + +```solidity +function deposit(uint256 amount) external nonReentrant returns (uint256 shares) +``` + +Transfers `amount` USDC from `msg.sender` into the vault and supplies it to Aave V3. Mints `shares` proportional to the current share price and assigns them to `msg.sender`. + +**Parameters:** + +| Name | Type | Description | +|---|---|---| +| `amount` | `uint256` | USDC amount to deposit (6 decimals) | + +**Returns:** + +| Name | Type | Description | +|---|---|---| +| `shares` | `uint256` | Vault shares minted to `msg.sender` | + +**Reverts:** + +| Error | Condition | +|---|---| +| `ZeroAmount` | `amount == 0` | +| `ZeroSharesMinted` | deposit rounds to 0 shares (only possible with dust amounts after large yield accrual) | +| `ERC20CallFailed` | `usdc.transferFrom` or `usdc.approve` fails | + +**Requirements:** Caller must have approved the vault to spend at least `amount` USDC before calling. + +**Emits:** `Deposited(msg.sender, amount, shares)` + +--- + +### withdraw + +```solidity +function withdraw(uint256 shares) external nonReentrant returns (uint256 payout) +``` + +Redeems `shares` from `msg.sender`, withdraws the corresponding USDC from Aave, deducts the protocol fee from the yield portion, and sends the net payout to `msg.sender`. The fee is sent to `treasury`. + +**Parameters:** + +| Name | Type | Description | +|---|---|---| +| `shares` | `uint256` | Number of vault shares to redeem | + +**Returns:** + +| Name | Type | Description | +|---|---|---| +| `payout` | `uint256` | USDC received by `msg.sender` (after fee) | + +**Reverts:** + +| Error | Condition | +|---|---| +| `ZeroAmount` | `shares == 0` | +| `InsufficientShares` | `shares > userShares[msg.sender]` | +| `ERC20CallFailed` | any USDC transfer fails | + +**Emits:** `Withdrawn(msg.sender, shares, grossAssets, fee, payout)` + +--- + +## View Functions + +### getVaultBalance + +```solidity +function getVaultBalance() external view returns (uint256) +``` + +Returns the total aUSDC balance held by the vault. This equals the sum of all user deposits plus all accrued Aave yield, minus any past withdrawals. + +--- + +### getUserBalance + +```solidity +function getUserBalance(address user) external view returns (uint256) +``` + +Returns the net USDC amount `user` would receive if they called `withdraw` with all their shares right now. Returns `0` if the user has no shares. + +The returned value accounts for the protocol fee on any yield. + +--- + +### previewDeposit + +```solidity +function previewDeposit(uint256 amount) external view returns (uint256) +``` + +Returns the number of shares that would be minted for a deposit of `amount` USDC at the current share price. + +--- + +### previewWithdraw + +```solidity +function previewWithdraw(uint256 shares) external view returns (uint256) +``` + +Returns the payout `msg.sender` would receive for redeeming `shares`. Returns `0` if the caller has fewer than `shares`. + +--- + +### previewWithdrawFor + +```solidity +function previewWithdrawFor(address user, uint256 shares) + external + view + returns (uint256 payout, uint256 grossAssets, uint256 fee) +``` + +Full withdrawal preview for any `user`. Returns all three components of the withdrawal calculation. + +| Return | Type | Description | +|---|---|---| +| `payout` | `uint256` | Net USDC `user` would receive | +| `grossAssets` | `uint256` | USDC value of `shares` before fee | +| `fee` | `uint256` | Protocol fee amount | + +--- + +## Events + +### Deposited + +```solidity +event Deposited(address indexed user, uint256 assets, uint256 shares) +``` + +Emitted on every successful `deposit` call. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `address` (indexed) | Depositing address | +| `assets` | `uint256` | USDC deposited | +| `shares` | `uint256` | Vault shares minted | + +--- + +### Withdrawn + +```solidity +event Withdrawn( + address indexed user, + uint256 shares, + uint256 grossAssets, + uint256 fee, + uint256 payout +) +``` + +Emitted on every successful `withdraw` call. + +| Parameter | Type | Description | +|---|---|---| +| `user` | `address` (indexed) | Withdrawing address | +| `shares` | `uint256` | Shares redeemed | +| `grossAssets` | `uint256` | USDC value of those shares before fee | +| `fee` | `uint256` | Protocol fee paid to treasury | +| `payout` | `uint256` | Net USDC sent to user (`grossAssets - fee`) | + +--- + +## Custom Errors + +```solidity +error ZeroAddress(); +error ZeroAmount(); +error InvalidFeeRate(); +error InsufficientShares(); +error ZeroSharesMinted(); +error ERC20CallFailed(); +``` + +| Error | Selector | When | +|---|---|---| +| `ZeroAddress` | `0xd92e233d` | Constructor receives `address(0)` for any parameter | +| `ZeroAmount` | `0x1f2a2005` | `deposit` or `withdraw` called with `0` | +| `InvalidFeeRate` | — | Constructor `feeRate_` exceeds `MAX_FEE_BPS` | +| `InsufficientShares` | — | `withdraw` requested more shares than the caller holds | +| `ZeroSharesMinted` | — | `deposit` amount rounds to 0 shares | +| `ERC20CallFailed` | — | Any low-level ERC-20 call returns `false` or reverts | + +--- + +## Interfaces + +### IERC20 + +```solidity +// src/interfaces/IERC20.sol +interface IERC20 { + function transfer(address to, uint256 value) external returns (bool); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + function totalSupply() external view returns (uint256); +} +``` + +### IPool + +```solidity +// src/interfaces/IPool.sol +interface IPool { + function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; + function withdraw(address asset, uint256 amount, address to) external returns (uint256); +} +``` + +--- + +## ABI (JSON) + +The compiled ABI is written to `out/YieldSaveVault.sol/YieldSaveVault.json` after `forge build`. The relevant subset for frontend integration: + +```json +[ + { + "type": "function", + "name": "deposit", + "inputs": [{ "name": "amount", "type": "uint256" }], + "outputs": [{ "name": "shares", "type": "uint256" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "withdraw", + "inputs": [{ "name": "shares", "type": "uint256" }], + "outputs": [{ "name": "payout", "type": "uint256" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getVaultBalance", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getUserBalance", + "inputs": [{ "name": "user", "type": "address" }], + "outputs": [{ "name": "", "type": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "previewDeposit", + "inputs": [{ "name": "amount", "type": "uint256" }], + "outputs": [{ "name": "", "type": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "previewWithdraw", + "inputs": [{ "name": "shares", "type": "uint256" }], + "outputs": [{ "name": "", "type": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "previewWithdrawFor", + "inputs": [ + { "name": "user", "type": "address" }, + { "name": "shares", "type": "uint256" } + ], + "outputs": [ + { "name": "payout", "type": "uint256" }, + { "name": "grossAssets", "type": "uint256" }, + { "name": "fee", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Deposited", + "inputs": [ + { "name": "user", "type": "address", "indexed": true }, + { "name": "assets", "type": "uint256", "indexed": false }, + { "name": "shares", "type": "uint256", "indexed": false } + ] + }, + { + "type": "event", + "name": "Withdrawn", + "inputs": [ + { "name": "user", "type": "address", "indexed": true }, + { "name": "shares", "type": "uint256", "indexed": false }, + { "name": "grossAssets", "type": "uint256", "indexed": false }, + { "name": "fee", "type": "uint256", "indexed": false }, + { "name": "payout", "type": "uint256", "indexed": false } + ] + } +] +``` + +--- + +## Cast Quick Reference + +```bash +VAULT= +RPC= + +# Read vault state +cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC +cast call $VAULT "totalShares()(uint256)" --rpc-url $RPC +cast call $VAULT "feeRate()(uint256)" --rpc-url $RPC +cast call $VAULT "treasury()(address)" --rpc-url $RPC + +# Read user state +cast call $VAULT "getUserBalance(address)(uint256)" $USER --rpc-url $RPC +cast call $VAULT "userShares(address)(uint256)" $USER --rpc-url $RPC +cast call $VAULT "userDeposits(address)(uint256)" $USER --rpc-url $RPC + +# Preview operations +cast call $VAULT "previewDeposit(uint256)(uint256)" $AMOUNT --rpc-url $RPC +cast call $VAULT "previewWithdraw(uint256)(uint256)" $SHARES --rpc-url $RPC +cast call $VAULT "previewWithdrawFor(address,uint256)(uint256,uint256,uint256)" \ + $USER $SHARES --rpc-url $RPC + +# Query historical events +cast logs \ + --address $VAULT \ + --event "Deposited(address,uint256,uint256)" \ + --from-block $DEPLOY_BLOCK \ + --rpc-url $RPC + +cast logs \ + --address $VAULT \ + --event "Withdrawn(address,uint256,uint256,uint256,uint256)" \ + --from-block $DEPLOY_BLOCK \ + --rpc-url $RPC +``` diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..829257b --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,157 @@ +# Setup Guide + +## Prerequisites + +| Tool | Minimum version | Install | +|---|---|---| +| [Foundry](https://book.getfoundry.sh/) | ≥ 0.2 | `curl -L https://foundry.paradigm.xyz \| bash && foundryup` | +| Git | any | system package manager | + +No Node.js, Python, or Docker is required. The project is pure Solidity + Foundry. + +Confirm your Foundry installation: + +```bash +forge --version +cast --version +anvil --version +``` + +--- + +## Clone and Install + +```bash +git clone https://github.com/your-org/ys-contracts +cd ys-contracts + +# Install library dependencies (forge-std, openzeppelin-contracts) +forge install +``` + +Foundry uses git submodules for dependencies. If `forge install` exits without error but `lib/` is empty, initialise submodules manually: + +```bash +git submodule update --init --recursive +``` + +--- + +## Build + +```bash +forge build +``` + +This compiles `src/` with Solidity 0.8.30 (pinned in `foundry.toml`). Build artifacts go to `out/`. A clean build with no warnings is expected. + +```bash +make clean && forge build # full clean rebuild +``` + +--- + +## Environment Variables + +All runtime configuration — RPC endpoints, private keys, token addresses — is read from a `.env` file. Copy the template and fill in what you need: + +```bash +cp .env.example .env +``` + +The Makefile includes `.env` automatically. For `forge` commands run directly, source it first: + +```bash +source .env +``` + +### Variable reference + +| Variable | Purpose | Required for | +|---|---|---| +| `PRIVATE_KEY` | Signing key for Anvil transactions | Local deployment only | +| `DEPLOYER_PRIVATE_KEY` | Signing key for testnet/mainnet | Testnet + mainnet deploy | +| `TREASURY` | Fee recipient address | Any deployment | +| `FEE_RATE_BPS` | Protocol fee in basis points (default: `500`) | Any deployment | +| `ETHERSCAN_API_KEY` | Block explorer API key | Contract verification | +| `SEPOLIA_RPC_URL` | Sepolia JSON-RPC endpoint | Sepolia deploy + fork tests | +| `BASE_SEPOLIA_RPC_URL` | Base Sepolia JSON-RPC endpoint | Base Sepolia deploy + fork tests | +| `SEPOLIA_USDC` | USDC address on Sepolia | Sepolia deployment | +| `SEPOLIA_AUSDC` | aUSDC address on Sepolia | Sepolia deployment | +| `SEPOLIA_AAVE_POOL` | Aave V3 Pool address on Sepolia | Sepolia deployment | +| `BASE_SEPOLIA_USDC` | USDC address on Base Sepolia | Base Sepolia deployment | +| `BASE_SEPOLIA_AUSDC` | aUSDC address on Base Sepolia | Base Sepolia deployment | +| `BASE_SEPOLIA_AAVE_POOL` | Aave V3 Pool address on Base Sepolia | Base Sepolia deployment | + +**Minimum for running tests:** none — the standard test suite uses mock contracts and requires no RPC. + +**Minimum for fork tests:** `BASE_SEPOLIA_RPC_URL` (fork tests skip gracefully if unset). + +--- + +## Local Development with Anvil + +Anvil is Foundry's local EVM node. Use it to iterate quickly without spending testnet gas. + +```bash +# Terminal 1 — start Anvil +make anvil +# or: anvil + +# Anvil prints 10 funded accounts and their private keys. +# Use account 0 as your PRIVATE_KEY in .env: +# PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +# Terminal 2 — deploy to Anvil +make deploy NETWORK=anvil +``` + +The deployed vault address is printed to stdout and written to `deployments/anvil.json`. + +### Interacting with Anvil via cast + +```bash +VAULT=
+RPC=http://127.0.0.1:8545 + +# Read vault state +cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC + +# Send a transaction +cast send $VAULT "deposit(uint256)" 1000000000 \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC +``` + +--- + +## IDE Setup + +### VS Code + +Install the [Hardhat Solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension or [solidity](https://marketplace.visualstudio.com/items?itemName=JuanBlanco.solidity) extension for syntax highlighting and inline diagnostics. + +Configure remappings so the IDE resolves imports correctly. Both extensions read `remappings.txt` automatically. + +### Other editors + +Any editor with a Language Server Protocol client can use [solc-select](https://github.com/crytic/solc-select) + a Solidity language server. The `remappings.txt` at the repo root tells the LSP how to resolve `forge-std/` and `openzeppelin-contracts/` imports. + +--- + +## Updating Dependencies + +Dependencies are pinned as git submodules in `lib/`. To update to the latest compatible versions: + +```bash +forge update # update all +forge update lib/openzeppelin-contracts # update one +``` + +After updating, rebuild and rerun tests to confirm compatibility: + +```bash +forge build && forge test +``` + +Commit the updated submodule hashes and the `foundry.lock` file together. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..eda51b6 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,309 @@ +# Testing Guide + +## Overview + +The test suite is pure Foundry (Forge). It is organised into four layers — each serves a different purpose and has different speed and dependency requirements. + +| Layer | Location | Dependencies | Speed | +|---|---|---|---| +| Unit / scenario | `test/scenarios/`, `test/YieldSaveVault.t.sol` | Mock contracts only | Fast (< 1s) | +| Integration | `test/fork/` | Live Aave V3 via RPC | Slow (3–10s per test) | +| Fuzz | Any test with function parameters | Mock contracts only | Medium (256 runs × time per run) | + +--- + +## Test Structure + +``` +test/ + YieldSaveVault.t.sol Core unit tests + constructor validation + helpers/ + Fixtures.sol Abstract base: deploys vault + mocks, pre-funds alice/bob + AaveFork.sol Deploys MockERC20 + MockAavePool for unit test setups + BaseSepoliaFork.sol Forks Base Sepolia and wires real Aave addresses + scenarios/ + Deposit.t.sol Deposit guard checks, share minting, share price + Withdraw.t.sol Withdrawal flows, proportional principal reduction + Fee.t.sol Fee-only-on-yield, zero-yield no-fee, preview accuracy + ShareMath.t.sol Share price appreciation, later depositor dilution + mocks/ + MockERC20.sol Minimal ERC-20 with mint/burn + MockAavePool.sol Deterministic mock: supply, withdraw, accrueYield + fork/ + BaseSepoliaIntegration.t.sol Real deposit/withdraw/balance against Aave V3 +``` + +--- + +## Running Tests + +### All tests + +```bash +forge test +# or +make test +``` + +No environment variables are needed. Fork tests skip automatically when `BASE_SEPOLIA_RPC_URL` is unset. + +### With traces (recommended when debugging failures) + +```bash +forge test -vvvv +# or +make test-verbose +``` + +`-v` through `-vvvv` increase verbosity. `-vvvv` shows full call traces, storage reads/writes, and gas costs per call. + +### Specific file or test + +```bash +# Single file +forge test --match-path test/scenarios/Fee.t.sol + +# Single function (partial match) +forge test --match-test test_FeeOnlyAppliesToYield + +# All tests in a contract +forge test --match-contract FeeScenarios +``` + +### Watch mode + +```bash +forge test --watch +``` + +Re-runs all tests on every file save. Useful during active development. + +--- + +## Fork Tests + +Fork tests create a local EVM clone of Base Sepolia at the current block and run against the real deployed Aave V3 contracts. + +```bash +# Requires BASE_SEPOLIA_RPC_URL set in .env +make fork-base + +# Or directly: +forge test --fork-url $BASE_SEPOLIA_RPC_URL --match-path test/fork/ +``` + +Fork tests are **not** run in CI by default (they require a live RPC and are slower). Run them manually before opening a PR that touches Aave integration logic. + +### When to write fork tests vs mock tests + +Write a **mock test** when you want to verify logic in isolation — share math, fee calculation, guard conditions. Mock tests are deterministic and fast. + +Write a **fork test** when you need to confirm the Aave integration contract behaves exactly as expected on a live network — call signatures, return values, aUSDC balance changes. + +--- + +## Coverage + +```bash +make coverage +# or +forge coverage +``` + +Coverage output lists line and branch coverage per file. The project targets 100% line coverage on `src/`. Internal helpers (`_safeTransfer`, `_forceApprove`, `_previewDeposit`, etc.) should each have at least one direct test path. + +To see which specific lines are uncovered, use the LCOV report: + +```bash +forge coverage --report lcov +genhtml lcov.info --output-directory coverage-report +open coverage-report/index.html # macOS +``` + +--- + +## Gas Snapshots + +```bash +make gas +# or +forge snapshot +``` + +This writes `.gas-snapshot` in the repo root. The file is committed to version control — it acts as a gas regression check. If a change unexpectedly increases gas, the snapshot will diverge and CI will flag it. + +When you intentionally change gas costs, regenerate and commit the snapshot: + +```bash +forge snapshot +git add .gas-snapshot +``` + +--- + +## Mock Setup + +### MockERC20 + +A minimal ERC-20 with no restrictions: `transfer`, `approve`, `transferFrom`, `mint`, `burn`. Uses 6 decimal places by default (matching USDC). Tests use it as both the USDC and aUSDC stand-ins. + +### MockAavePool + +Simulates Aave V3's `supply` and `withdraw` mechanics deterministically: + +| Function | Behaviour | +|---|---| +| `supply(asset, amount, onBehalfOf, referralCode)` | Pulls `amount` USDC from `onBehalfOf`, mints equal aUSDC to `onBehalfOf` | +| `withdraw(asset, amount, to)` | Burns `amount` aUSDC from caller, transfers `amount` USDC to `to` | +| `accrueYield(amount)` | Mints `amount` aUSDC to vault + `amount` USDC to pool (simulates block-by-block yield) | + +`accrueYield` is test-only — it does not exist on real Aave. + +### Fixtures + +`Fixtures` is an abstract base contract for scenario tests: + +```solidity +abstract contract Fixtures is Test { + YieldSaveVault vault; + MockERC20 usdc; + MockERC20 aUsdc; + MockAavePool pool; + + address alice = address(0xA); + address bob = address(0xB); + address treasury = address(0xFEE); + + function setUp() public virtual { + // deploys mocks and vault, mints 1M USDC each to alice and bob + } + + function _deposit(address user, uint256 amount) internal { ... } + function _withdraw(address user, uint256 shares) internal { ... } + function _accrueYield(uint256 amount) internal { ... } +} +``` + +All scenario test files inherit `Fixtures`: + +```solidity +contract DepositScenarios is Fixtures { + function test_FirstDepositMintsSharesOneToOne() public { + _deposit(alice, 1_000e6); + assertEq(vault.userShares(alice), 1_000e6); + } +} +``` + +--- + +## Writing Tests + +### Scenario test (most common) + +1. Create `test/scenarios/YourFeature.t.sol` +2. Inherit `Fixtures` +3. Prefix test functions with `test_` + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Fixtures} from "../helpers/Fixtures.sol"; + +contract YourFeatureScenarios is Fixtures { + function test_SomeCondition() public { + _deposit(alice, 500e6); + _accrueYield(25e6); + + uint256 shares = vault.userShares(alice); + uint256 payout = vault.getUserBalance(alice); + + assertGt(payout, 500e6, "payout should include yield"); + } +} +``` + +### Fuzz test + +Add parameters to any test function — Foundry automatically fuzz-tests it: + +```solidity +function test_FuzzDepositShares(uint256 amount) public { + amount = bound(amount, 1e6, 1_000_000e6); // clamp to valid range + + vm.prank(alice); + usdc.approve(address(vault), amount); + + vm.prank(alice); + uint256 shares = vault.deposit(amount); + + assertEq(shares, amount); // first deposit is 1:1 +} +``` + +The `bound(value, min, max)` cheatcode from `forge-std` is essential for clamping inputs to valid ranges. Fuzz runs default to 256 (configured in `foundry.toml`). + +### Fork test + +1. Create `test/fork/YourIntegration.t.sol` +2. Inherit `BaseSepoliaFork` +3. The base class skips automatically when `BASE_SEPOLIA_RPC_URL` is unset + +```solidity +contract YourIntegrationTest is BaseSepoliaFork { + function test_RealAaveInteraction() public { + uint256 amount = 10e6; // 10 USDC + _deposit(testUser, amount); + + assertGt(vault.getVaultBalance(), 0); + } +} +``` + +### Revert tests + +Use `vm.expectRevert` to assert that a call reverts with a specific error: + +```solidity +function test_DepositRevertsOnZeroAmount() public { + vm.prank(alice); + vm.expectRevert(YieldSaveVault.ZeroAmount.selector); + vault.deposit(0); +} +``` + +--- + +## Conventions + +### Test function naming + +All test functions follow `test_{Description}` in PascalCase description, e.g.: + +``` +test_DepositRevertsOnZeroAmount +test_FirstDepositMintsSharesOneToOne +test_FeeOnlyAppliesToYield +test_SharePriceAppreciatesAsYieldAccrues +``` + +### Assertions + +Prefer the most specific assertion: + +| Use | Instead of | +|---|---| +| `assertEq(a, b)` | `assertTrue(a == b)` | +| `assertGt(a, b)` | `assertTrue(a > b)` | +| `assertApproxEqAbs(a, b, delta)` | manual tolerance check | + +Always include a failure message as the third argument when the assertion is non-obvious. + +### `vm.prank` vs `vm.startPrank` + +Use `vm.prank(user)` for a single call. Use `vm.startPrank(user)` + `vm.stopPrank()` when a test requires multiple consecutive calls from the same address. + +### No `console.log` in committed tests + +`console2.log` calls are acceptable during debugging but must be removed before merging. They add noise to test output and slow down runs. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..98e48f9 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,311 @@ +# Troubleshooting + +## Build Issues + +### `forge build` fails with "Source not found" + +``` +Error: Source "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol" not found +``` + +**Cause:** Library submodules are not initialised. + +**Fix:** +```bash +forge install +# or, if forge install completes but the error persists: +git submodule update --init --recursive +``` + +--- + +### `forge build` fails with wrong Solidity version + +``` +Error: Source file requires different compiler version +``` + +**Cause:** Your local `solc` does not match the version pinned in `foundry.toml` (`solc = "0.8.30"`). + +**Fix:** +```bash +foundryup # updates forge/cast/anvil to latest +# Foundry manages its own solc binaries — it will download 0.8.30 automatically on next build +forge build +``` + +--- + +### Remapping errors (imports not resolved) + +``` +Error: No such file or directory: lib/forge-std/src/Test.sol +``` + +**Cause:** `remappings.txt` is present but the `lib/` directory is missing or incomplete. + +**Fix:** +```bash +forge install +cat remappings.txt # confirm it contains: forge-std/=lib/forge-std/src/ +``` + +--- + +## Test Failures + +### Fork tests are skipped / "no tests matched" + +**Cause:** `BASE_SEPOLIA_RPC_URL` is not set, so fork tests skip themselves. + +**Fix:** +```bash +# Add to .env: +BASE_SEPOLIA_RPC_URL=https://base-sepolia.g.alchemy.com/v2/YOUR_KEY + +source .env +make fork-base +``` + +--- + +### Fork test fails with "connection refused" or timeout + +**Cause:** RPC endpoint is unreachable or rate-limited. + +**Fix:** +- Confirm the URL in `.env` is correct and the API key is valid +- Try a different RPC provider (Alchemy, Infura, Ankr) +- Check the Alchemy/Infura dashboard for rate limit status + +--- + +### Test fails with "ERC20: insufficient allowance" + +**Cause:** The test calls `vault.deposit` but the mock USDC `approve` was not called first. + +**Fix:** Ensure the test calls `approve` before `deposit`: + +```solidity +vm.prank(alice); +usdc.approve(address(vault), amount); + +vm.prank(alice); +vault.deposit(amount); +``` + +Or use the `_deposit` helper from `Fixtures`, which handles approval internally. + +--- + +### Fuzz test fails with an unexpected revert + +**Cause:** The fuzz input is out of a valid range (e.g. `amount = 0` hitting `ZeroAmount`). + +**Fix:** Clamp inputs with `bound`: + +```solidity +function test_FuzzWithdraw(uint256 shares) public { + shares = bound(shares, 1, vault.userShares(alice)); + ... +} +``` + +--- + +### `vm.expectRevert` does not match + +``` +Error: Expected revert, but the call succeeded +``` + +or + +``` +Error: Reverted with custom error, but expected panic +``` + +**Cause:** The wrong error selector is used, or the revert happens in a different function than expected. + +**Fix:** Use the exact custom error selector: + +```solidity +vm.expectRevert(YieldSaveVault.ZeroAmount.selector); +vault.deposit(0); +``` + +Do not use string selectors for custom errors — they only work for `require`-style reverts. + +--- + +## Deployment Issues + +### Deploy fails with "private key not set" + +``` +Error: environment variable not found: DEPLOYER_PRIVATE_KEY +``` + +**Fix:** Set `DEPLOYER_PRIVATE_KEY` in `.env` and ensure `.env` is loaded: + +```bash +source .env +make deploy NETWORK=sepolia +``` + +The Makefile `include .env` loads the file automatically when using `make`. Direct `forge` commands need `source .env` first. + +--- + +### Deploy fails with "insufficient funds" + +**Cause:** The deployer wallet does not hold enough native token (ETH on Sepolia, ETH on Base Sepolia) to pay for gas. + +**Fix:** +- Sepolia ETH faucet: [sepoliafaucet.com](https://sepoliafaucet.com) or Alchemy faucet +- Base Sepolia ETH: bridge from Sepolia or use the Coinbase faucet + +--- + +### Etherscan verification fails after deployment + +``` +Error: Contract source code already verified +``` +or +``` +Error: Unable to verify — Rate limited +``` + +**Fix:** Re-run verification separately: + +```bash +make verify NETWORK=sepolia ADDRESS=0xYourVaultAddress +``` + +If Etherscan is rate-limiting, wait a few minutes and retry. Verification is cosmetic — the contract is deployed and functional regardless. + +--- + +### Deployment writes to wrong `deployments/` file + +**Cause:** The wrong `NETWORK` was passed to `make deploy`, or `block.chainid` in the script doesn't match the expected network. + +**Fix:** +```bash +# Check which chain the RPC connects to +cast chain-id --rpc-url $YOUR_RPC_URL + +# Confirm it matches what Deploy.s.sol expects: +# 11155111 = Sepolia, 84532 = Base Sepolia +``` + +--- + +### `make deploy NETWORK=mainnet` fails — unsupported chain + +**Cause:** Mainnet (`chainId == 1`) is not yet handled in `_loadNetworkConfig` in `Deploy.s.sol`. + +**Fix:** Add mainnet address support to the script before deploying. See the [Deployment Guide — Adding a New Network](deployment.md#adding-a-new-network). + +--- + +## On-Chain Issues + +### `withdraw` reverts — Aave liquidity crunch + +``` +Error: execution reverted +``` + +When calling `vault.withdraw` on a live network and Aave's withdraw reverts, the most likely cause is high utilisation (all USDC is lent out). + +**Diagnosis:** +```bash +# Check aUSDC balance in Aave's pool (withdrawable liquidity) +cast call $AUSDC "balanceOf(address)(uint256)" $AAVE_POOL --rpc-url $RPC +``` + +If this value is low relative to the vault's total assets, withdrawals are temporarily blocked by Aave. + +**Resolution:** Wait for borrowers to repay. No contract action is needed. + +--- + +### `deposit` or `withdraw` reverts with `ERC20CallFailed` + +**Cause:** A low-level ERC-20 call failed. Possible reasons: +- USDC allowance not set before deposit +- Caller's USDC balance is lower than `amount` +- Circle has blacklisted the vault or user address (rare) + +**Diagnosis:** +```bash +# Check USDC allowance +cast call $USDC "allowance(address,address)(uint256)" $USER $VAULT --rpc-url $RPC + +# Check USDC balance +cast call $USDC "balanceOf(address)(uint256)" $USER --rpc-url $RPC +``` + +--- + +### User balance shows 0 but shares are non-zero + +**Cause:** `getUserBalance` calls `previewWithdrawFor`, which returns `0` when `shares > userShares[user]`. This should not be possible through normal usage. + +**Diagnosis:** +```bash +cast call $VAULT "userShares(address)(uint256)" $USER --rpc-url $RPC +cast call $VAULT "totalShares()(uint256)" --rpc-url $RPC +cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC +``` + +If `userShares > 0` and `getVaultBalance > 0`, the user does have a positive balance — call `previewWithdrawFor` with the exact share amount. + +--- + +## Environment / Toolchain + +### `make` fails with "Makefile:12: .env: No such file or directory" + +**Fix:** +```bash +cp .env.example .env +# then fill in the values you need +``` + +--- + +### `forge test --watch` does not detect file changes on macOS + +**Cause:** Foundry's file watcher may not work with some macOS configurations. + +**Fix:** Use `nodemon` as a workaround: +```bash +brew install nodemon +nodemon --watch src --watch test --ext sol --exec "forge test" +``` + +--- + +### `cast` returns raw hex instead of decoded values + +**Fix:** Include the return type in the function signature: + +```bash +# Wrong (returns raw hex) +cast call $VAULT "getVaultBalance()" --rpc-url $RPC + +# Correct (decoded uint256) +cast call $VAULT "getVaultBalance()(uint256)" --rpc-url $RPC +``` + +--- + +## Getting More Help + +- **Foundry documentation:** [book.getfoundry.sh](https://book.getfoundry.sh) +- **Aave V3 developer docs:** [docs.aave.com/developers](https://docs.aave.com/developers) +- **OpenZeppelin contracts:** [docs.openzeppelin.com/contracts](https://docs.openzeppelin.com/contracts) +- **Open a bug report:** [GitHub Issues](https://github.com/your-org/ys-contracts/issues)