Skip to main content
ClaudeWave
Skill55 repo starsupdated 4mo ago

vault-erc4626

>

Install in Claude Code
Copy
git clone --depth 1 https://github.com/PurpleAILAB/Vigilo /tmp/vault-erc4626 && cp -r /tmp/vault-erc4626/packages/opencode/skills/vault-erc4626 ~/.claude/skills/vault-erc4626
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# ERC4626 Vault Patterns

This skill provides comprehensive knowledge for auditing ERC4626 tokenized vaults.

## ERC4626 Overview

ERC4626 standardizes tokenized vaults:
- Deposit assets → receive shares
- Shares represent proportional ownership
- Redeem shares → receive assets

```
shares = assets × totalSupply / totalAssets
assets = shares × totalAssets / totalSupply
```

---

## Why ERC4626 Vault Attacks Happen (Root Causes)

### Root Cause 1: Share Price Manipulation via Donation

Attacker donates assets directly to vault without minting shares, inflating `price = totalAssets / totalSupply`. Next depositor pays inflated price; attacker's shares gain value.

**Detection**: Verify `totalAssets()` uses internal accounting, not `balanceOf()`.

### Root Cause 2: First Depositor Advantage

When `totalSupply = 0`, first depositor deposits 1 wei (gets 1 share), then donates massive assets. Subsequent depositors suffer rounding losses: `2e18 * 1 / (1e18 + 1) = 1 share` (rounds down).

**Detection**: Check for virtual shares, dead shares, or minimum deposit mitigations.

### Root Cause 3: Rounding Direction Errors

Wrong rounding direction allows value extraction. Withdraw should round UP (burn more shares), not DOWN.

```solidity
// Vulnerable: withdraw(assets) returns assets * supply / totalAssets (rounds DOWN)
// Correct: withdraw(assets) returns (assets * supply + totalAssets - 1) / totalAssets (rounds UP)
```

**Detection**: Verify deposit↓, mint↑, withdraw↑, redeem↓ rounding directions.

### Root Cause 4: State Desynchronization with External Yield

`totalAssets()` depends on external protocol state (Aave, Lido). If external protocol pauses or is manipulated, vault accounting breaks.

**Detection**: Check if `totalAssets()` can be manipulated by external protocols.

---

## Inflation Attack (First Depositor Attack)

### The Attack

```
1. Vault is empty (totalSupply = 0, totalAssets = 0)
2. Attacker deposits 1 wei → gets 1 share
3. Attacker donates 1e18 tokens directly to vault
4. Now: totalSupply = 1, totalAssets = 1e18 + 1
5. Victim deposits 2e18 tokens
6. Victim gets: 2e18 × 1 / (1e18 + 1) = 1 share (rounds down!)
7. Attacker and victim each have 1 share
8. Attacker redeems: gets half of 3e18 + 1 = 1.5e18 tokens
9. Attacker profit: ~0.5e18 tokens stolen from victim
```

### Vulnerable Pattern

```solidity
// DANGEROUS: No inflation protection
function deposit(uint256 assets) public returns (uint256 shares) {
    shares = totalSupply() == 0
        ? assets
        : assets * totalSupply() / totalAssets();

    _mint(msg.sender, shares);
    asset.transferFrom(msg.sender, address(this), assets);
}
```

### Mitigation 1: Virtual Shares/Assets

```solidity
// Add virtual offset to prevent manipulation
uint256 constant VIRTUAL_SHARES = 1e3;
uint256 constant VIRTUAL_ASSETS = 1;

function totalSupply() public view override returns (uint256) {
    return super.totalSupply() + VIRTUAL_SHARES;
}

function totalAssets() public view override returns (uint256) {
    return asset.balanceOf(address(this)) + VIRTUAL_ASSETS;
}
```

### Mitigation 2: Dead Shares (Burn on First Deposit)

```solidity
function deposit(uint256 assets) public returns (uint256 shares) {
    shares = _convertToShares(assets);

    if (totalSupply() == 0) {
        // Burn first 1000 shares to dead address
        uint256 deadShares = 1000;
        _mint(address(0xdead), deadShares);
        shares -= deadShares;
    }

    _mint(msg.sender, shares);
    asset.transferFrom(msg.sender, address(this), assets);
}
```

### Mitigation 3: Minimum Deposit

```solidity
function deposit(uint256 assets) public returns (uint256 shares) {
    require(assets >= MIN_DEPOSIT, "Below minimum");
    // ...
}
```

---

## Rounding Direction

### The Rule

| Operation | Round | Reason |
|-----------|-------|--------|
| deposit | shares DOWN | User gets less shares |
| mint | assets UP | User pays more |
| withdraw | shares UP | User burns more |
| redeem | assets DOWN | User gets less |

**Always round in favor of the vault (against the user).**

### Implementation

```solidity
// Round DOWN (default)
function _convertToShares(uint256 assets) internal view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0 ? assets : assets * supply / totalAssets();
}

// Round UP
function _convertToSharesRoundUp(uint256 assets) internal view returns (uint256) {
    uint256 supply = totalSupply();
    if (supply == 0) return assets;
    return (assets * supply + totalAssets() - 1) / totalAssets();
}
```

### Vulnerable Pattern

```solidity
// DANGEROUS: Wrong rounding direction
function withdraw(uint256 assets) public returns (uint256 shares) {
    // Should round UP, but rounds DOWN
    shares = assets * totalSupply() / totalAssets();
    // Attacker can withdraw dust repeatedly, extracting value
}
```

---

## Donation Attack

### The Attack

```
1. Attacker deposits normally, gets shares
2. Attacker donates assets directly to vault (not through deposit)
3. Share price increases
4. Attacker has fewer shares but same value
5. Other share calculations become incorrect
```

### Vulnerable Scenarios

```solidity
// If vault uses asset balance directly
function totalAssets() public view returns (uint256) {
    return asset.balanceOf(address(this)); // Manipulable!
}

// If reward distribution based on balance
function distribute() external {
    uint256 rewards = asset.balanceOf(address(this)) - lastBalance;
    // Donated amount included in rewards!
}
```

### Mitigations

```solidity
// Track assets internally
uint256 internal _totalAssets;

function deposit(uint256 assets) public {
    // ...
    _totalAssets += assets;
}

function totalAssets() public view returns (uint256) {
    return _totalAssets; // Not manipulable
}
```

---

## Share Calculation Edge Cases

### Zero Total Supply

```solidity
function convertToShares(uint256 assets) public view returns (uint256) {
    uint256 supply = totalSupply();
    // Handle first deposit