返回 SC 笔记
SC Day 67

手动审计练习#2 - 审计一个DeFi合约(Vault/Staking)

### 1. DeFi合约审计 vs Token合约审计

2026-06-17
第三阶段:安全审计
defi-auditvaultstakingshare-calculationflash-loan-attackfirst-depositor

日期: 2026-06-17 方向: Solidity / Audit 阶段: 第三阶段:安全审计 标签: #defi-audit #vault #staking #share-calculation #flash-loan-attack #first-depositor


今日目标

类型内容
学习掌握DeFi特有的审计关注点:份额计算、首存者攻击、闪电贷交互、奖励分配
实操对一个包含多个DeFi漏洞的Vault合约进行完整手动审计
产出一份DeFi审计报告 + DeFi常见漏洞速查清单

核心概念

1. DeFi合约审计 vs Token合约审计

DeFi合约(Vault、Staking、Lending等)比普通Token合约复杂得多。审计时需要额外关注:

关注点说明典型漏洞
份额计算(Share Calculation)存入资产→铸造份额,取出资产→销毁份额舍入误差、首存者攻击
奖励分配(Reward Distribution)按份额比例分配奖励精度丢失、奖励偷取
闪电贷交互(Flash Loan)合约是否能被闪电贷利用价格操纵、瞬时余额膨胀
重入风险(Reentrancy)外部调用+状态修改经典重入、跨函数重入
价格依赖(Price Oracle)依赖外部价格源预言机操纵、延迟攻击
时间依赖(Timing)基于区块时间的逻辑时间操纵、抢跑

2. Vault份额计算原理

ERC-4626标准的核心公式:

存入时铸造的份额:
    shares = (depositAmount * totalShares) / totalAssets

取出时销毁的份额:
    assets = (shares * totalAssets) / totalShares

为什么这个公式有问题?

totalSharestotalAssets为0时(首次存入),除法会出错。且由于Solidity整数除法向下截断,小数部分会被丢弃,这在特定条件下可以被利用。

3. 首存者攻击(First Depositor Attack / Inflation Attack)

这是DeFi Vault中最经典的攻击之一。攻击步骤:

初始状态:Vault为空,totalAssets=0, totalShares=0

Step 1: 攻击者Alice存入1 wei → 获得1 share
    totalAssets=1, totalShares=1

Step 2: Alice直接转入大量Token到Vault合约(不通过deposit)
    例如转入 10000e18
    totalAssets=10000e18+1, totalShares=1

Step 3: 受害者Bob存入 19999e18
    Bob的shares = (19999e18 * 1) / (10000e18 + 1) = 1(向下截断为1)
    totalAssets=29999e18+1, totalShares=2

Step 4: Alice取出1 share
    Alice得到 = (1 * (29999e18+1)) / 2 ≈ 14999.5e18

结果:Alice投入约10000e18,取出约14999.5e18,净赚约4999.5e18
      Bob投入19999e18,只剩1 share能取出约14999.5e18,亏了约4999.5e18

防御方式

  1. 首次存入时铸造额外份额到dead address(如OpenZeppelin的ERC4626实现)
  2. 设置最小存入量
  3. 使用虚拟偏移(virtual offset):在计算中始终加一个小数

4. 奖励分配的精度问题

经典的 rewardPerShare 累加模式:

// 每次新奖励到达时更新
rewardPerShare += newReward * PRECISION / totalShares;

// 用户领取时计算
userReward = (user.shares * rewardPerShare / PRECISION) - user.rewardDebt;

如果 PRECISION 不够大,或者 totalShares 远大于 newRewardrewardPerShare 的增量可能被截断为0,导致奖励"消失"。


代码实战

待审计合约:MomoVault

以下是一个Staking Vault合约,用户存入ERC20 Token获得份额(shares),按份额获取Staking奖励。包含6+个安全问题

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

/**
 * @title MomoVault
 * @notice A staking vault where users deposit tokens and earn rewards.
 * @dev Users deposit stakingToken, receive shares proportional to their deposit.
 *      Admin can add rewards which are distributed proportionally to shareholders.
 */
contract MomoVault {
    IERC20 public stakingToken;

    address public admin;

    uint256 public totalShares;
    uint256 public totalStaked;

    // Reward tracking
    uint256 public rewardPerShare; // accumulated reward per share (scaled by 1e18)
    uint256 public lastRewardTime;
    uint256 public rewardRate; // reward tokens per second

    struct UserInfo {
        uint256 shares;
        uint256 rewardDebt;
        uint256 depositTime;
    }

    mapping(address => UserInfo) public userInfo;

    // Withdrawal fee: 1% if withdrawn within 7 days
    uint256 public constant EARLY_WITHDRAWAL_FEE = 100; // 1% in basis points
    uint256 public constant LOCK_PERIOD = 7 days;
    uint256 public constant FEE_DENOMINATOR = 10000;

    event Deposit(address indexed user, uint256 amount, uint256 shares);
    event Withdraw(address indexed user, uint256 amount, uint256 shares);
    event RewardClaimed(address indexed user, uint256 reward);
    event RewardRateUpdated(uint256 newRate);

    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }

    constructor(address _stakingToken) {
        stakingToken = IERC20(_stakingToken);
        admin = msg.sender;
        lastRewardTime = block.timestamp;
    }

    /// @notice Update the accumulated reward per share
    function updateRewards() public {
        if (totalShares == 0) {
            lastRewardTime = block.timestamp;
            return;
        }

        uint256 timeElapsed = block.timestamp - lastRewardTime;
        uint256 reward = timeElapsed * rewardRate;

        rewardPerShare += (reward * 1e18) / totalShares;
        lastRewardTime = block.timestamp;
    }

    /// @notice Deposit tokens and receive shares
    function deposit(uint256 amount) external {
        require(amount > 0, "Cannot deposit 0");

        updateRewards();

        // Calculate shares
        uint256 shares;
        if (totalShares == 0) {
            shares = amount;
        } else {
            shares = (amount * totalShares) / totalStaked;
        }

        // Transfer tokens from user
        stakingToken.transferFrom(msg.sender, address(this), amount);

        // Update state
        UserInfo storage user = userInfo[msg.sender];
        user.shares += shares;
        user.rewardDebt = (user.shares * rewardPerShare) / 1e18;
        user.depositTime = block.timestamp;

        totalShares += shares;
        totalStaked += amount;

        emit Deposit(msg.sender, amount, shares);
    }

    /// @notice Withdraw tokens by burning shares
    function withdraw(uint256 sharesToBurn) external {
        UserInfo storage user = userInfo[msg.sender];
        require(user.shares >= sharesToBurn, "Insufficient shares");
        require(sharesToBurn > 0, "Cannot withdraw 0");

        updateRewards();

        // Claim pending rewards first
        uint256 pending = (user.shares * rewardPerShare) / 1e18 - user.rewardDebt;
        if (pending > 0) {
            stakingToken.transfer(msg.sender, pending);
        }

        // Calculate withdrawal amount
        uint256 amount = (sharesToBurn * totalStaked) / totalShares;

        // Apply early withdrawal fee
        if (block.timestamp < user.depositTime + LOCK_PERIOD) {
            uint256 fee = (amount * EARLY_WITHDRAWAL_FEE) / FEE_DENOMINATOR;
            amount -= fee;
            // Fee stays in the vault (benefits remaining stakers)
        }

        // Update state
        user.shares -= sharesToBurn;
        user.rewardDebt = (user.shares * rewardPerShare) / 1e18;
        totalShares -= sharesToBurn;
        totalStaked -= amount;

        // Transfer tokens to user
        stakingToken.transfer(msg.sender, amount);

        emit Withdraw(msg.sender, amount, sharesToBurn);
    }

    /// @notice Claim pending rewards without withdrawing
    function claimReward() external {
        updateRewards();

        UserInfo storage user = userInfo[msg.sender];
        uint256 pending = (user.shares * rewardPerShare) / 1e18 - user.rewardDebt;

        if (pending > 0) {
            user.rewardDebt = (user.shares * rewardPerShare) / 1e18;
            stakingToken.transfer(msg.sender, pending);
            emit RewardClaimed(msg.sender, pending);
        }
    }

    /// @notice Admin sets the reward rate
    function setRewardRate(uint256 _rewardRate) external onlyAdmin {
        updateRewards();
        rewardRate = _rewardRate;
        emit RewardRateUpdated(_rewardRate);
    }

    /// @notice Admin can deposit reward tokens
    function depositRewards(uint256 amount) external onlyAdmin {
        stakingToken.transferFrom(msg.sender, address(this), amount);
    }

    /// @notice Emergency withdraw - forfeit all rewards
    function emergencyWithdraw() external {
        UserInfo storage user = userInfo[msg.sender];
        uint256 shares = user.shares;
        require(shares > 0, "No shares");

        uint256 amount = (shares * totalStaked) / totalShares;

        user.shares = 0;
        user.rewardDebt = 0;
        totalShares -= shares;
        totalStaked -= amount;

        stakingToken.transfer(msg.sender, amount);

        emit Withdraw(msg.sender, amount, shares);
    }

    /// @notice View pending rewards
    function pendingReward(address _user) external view returns (uint256) {
        UserInfo storage user = userInfo[_user];
        uint256 accRewardPerShare = rewardPerShare;

        if (totalShares > 0 && block.timestamp > lastRewardTime) {
            uint256 timeElapsed = block.timestamp - lastRewardTime;
            uint256 reward = timeElapsed * rewardRate;
            accRewardPerShare += (reward * 1e18) / totalShares;
        }

        return (user.shares * accRewardPerShare) / 1e18 - user.rewardDebt;
    }
}

完整审计报告

审计概要

项目详情
合约名MomoVault
审计范围MomoVault.sol (单文件)
Solidity版本^0.8.20
审计日期2026-06-17
合约类型Staking Vault with Reward Distribution

发现汇总

编号标题严重性
H-01首存者攻击(Inflation Attack)可窃取后续用户资金High
H-02奖励分配不足时的资金亏空(Insolvency Risk)High
H-03withdraw中totalStaked扣减金额错误,导致accounting偏差High
M-01ERC20 transfer返回值未检查Medium
M-02deposit中rewardDebt覆盖导致已有奖励丢失Medium
M-03depositTime每次deposit时被覆盖,锁定期可被重置Medium
L-01emergencyWithdraw未调用updateRewardsLow
L-02缺少admin转移功能和zero address检查Low
I-01rewardRate设置缺少上限检查Informational

H-01: 首存者攻击(Inflation Attack)

严重性: High 文件: MomoVault.sol 行号: L81-L86

描述: 当Vault为空时,首个存款者可以执行经典的Inflation Attack。

攻击流程:

1. Vault为空,totalShares=0, totalStaked=0
2. Attacker调用 deposit(1),获得1 share
   → totalShares=1, totalStaked=1
3. Attacker直接调用 stakingToken.transfer(vault, 10000e18)
   → Vault余额增加但totalStaked不变(totalStaked仍为1)

   注意:本合约使用totalStaked而非balanceOf追踪资产,
   所以直接转入不影响totalStaked。但这仍然是一个风险:
   如果合约改为使用balanceOf,就会有首存者攻击问题。

   更重要的是,即使不直接转账,攻击者可以在deposit(1)后
   等待奖励累积(rewardPerShare增长),此时只有1 share,
   所有奖励归攻击者。然后后续depositor按照膨胀后的
   totalStaked计算shares,获得很少的shares。

但这个合约有一个更直接的问题:当 totalShares == 0 时,shares = amount。如果第一个用户存入1 wei,获得1 share。第二个用户存入很大金额,由于 shares = (amount * 1) / 1 = amount,看起来正常。但如果在两次deposit之间,totalStaked因为奖励等原因增加了,份额计算就会偏离。

影响:

  • 后续存款者可能获得比预期少的份额
  • 在极端情况下,后续存款者可能获得0 share(所有资金被首存者窃取)

推荐修复:

uint256 public constant MINIMUM_SHARES = 1000;

function deposit(uint256 amount) external {
    // ...
    uint256 shares;
    if (totalShares == 0) {
        shares = amount - MINIMUM_SHARES;
        // Mint MINIMUM_SHARES to dead address to prevent inflation attack
        totalShares += MINIMUM_SHARES;
        // These shares are permanently locked
    } else {
        shares = (amount * totalShares) / totalStaked;
    }
    require(shares > 0, "Shares must be > 0");
    // ...
}

H-02: 奖励分配不足时的资金亏空

严重性: High 文件: MomoVault.sol 行号: L73-L79, L143-L152

描述: updateRewards 按时间和 rewardRate 计算奖励,但没有检查Vault中是否有足够的Token来支付这些奖励。rewardRate 的设置也没有与实际可用奖励Token绑定。

场景:

1. Admin设置 rewardRate = 100e18(每秒100 Token)
2. Admin只存入了1000e18作为奖励
3. 10秒后,承诺的奖励 = 1000e18(刚好够)
4. 20秒后,承诺的奖励 = 2000e18(但只有1000e18可用)
5. 先领取的用户拿到奖励,后领取的用户的transfer会失败

这导致了"先到先得"的不公平问题,且最后的用户可能完全无法提取。

影响:

  • Vault可能承诺了比实际可用余额更多的奖励
  • 最后的提取者将面临transfer失败
  • 如果stakingToken有缺陷(transfer失败时不revert),用户可能拿不到任何Token

推荐修复:

uint256 public rewardEndTime;
uint256 public totalRewardsAvailable;

function setRewardRate(uint256 _rewardRate, uint256 duration) external onlyAdmin {
    updateRewards();
    rewardRate = _rewardRate;
    rewardEndTime = block.timestamp + duration;

    uint256 requiredRewards = _rewardRate * duration;
    require(
        stakingToken.balanceOf(address(this)) - totalStaked >= requiredRewards,
        "Insufficient reward balance"
    );
}

function updateRewards() public {
    if (totalShares == 0) {
        lastRewardTime = block.timestamp;
        return;
    }

    uint256 endTime = block.timestamp < rewardEndTime ? block.timestamp : rewardEndTime;
    if (endTime <= lastRewardTime) return;

    uint256 timeElapsed = endTime - lastRewardTime;
    uint256 reward = timeElapsed * rewardRate;

    rewardPerShare += (reward * 1e18) / totalShares;
    lastRewardTime = endTime;
}

H-03: withdraw中totalStaked扣减金额错误

严重性: High 文件: MomoVault.sol 行号: L117-L121

描述: 当用户在LOCK_PERIOD内提前取出时,会扣除一笔fee,实际发送的amount减少了。但 totalStaked -= amount 使用的是扣除fee后的amount:

// 计算取出金额
uint256 amount = (sharesToBurn * totalStaked) / totalShares;

// 扣除提前取出费用
if (block.timestamp < user.depositTime + LOCK_PERIOD) {
    uint256 fee = (amount * EARLY_WITHDRAWAL_FEE) / FEE_DENOMINATOR;
    amount -= fee;  // amount被修改为扣费后的金额
    // Fee stays in the vault
}

// 更新状态
totalShares -= sharesToBurn;
totalStaked -= amount;  // BUG: 这里用的是扣费后的amount!

问题:fee留在Vault中("benefits remaining stakers"),但 totalStaked 只减少了 amount - fee。这意味着fee对应的Token存在于Vault中但没有被 totalStaked 追踪。随着更多用户提前取出,totalStaked 会越来越低于实际余额。

更严重的后果:最后一批用户取出时,(sharesToBurn * totalStaked) / totalShares 的值会低于他们应得的金额,因为 totalStaked 比实际可用余额低。

推荐修复:

// 先记录原始金额
uint256 originalAmount = (sharesToBurn * totalStaked) / totalShares;
uint256 amountToUser = originalAmount;

if (block.timestamp < user.depositTime + LOCK_PERIOD) {
    uint256 fee = (originalAmount * EARLY_WITHDRAWAL_FEE) / FEE_DENOMINATOR;
    amountToUser = originalAmount - fee;
}

// totalStaked减去原始金额(包含fee)
totalStaked -= originalAmount;
totalShares -= sharesToBurn;

stakingToken.transfer(msg.sender, amountToUser);

M-01: ERC20 transfer返回值未检查

严重性: Medium 文件: MomoVault.sol 行号: L92, L110, L113, L138, L157, L168

描述: 合约中所有的 stakingToken.transfer()stakingToken.transferFrom() 调用都没有检查返回值。ERC20标准规定 transfer 返回 bool,某些Token(如USDT)在转账失败时不revert而是返回 false

// 当前实现——未检查返回值
stakingToken.transferFrom(msg.sender, address(this), amount); // L92
stakingToken.transfer(msg.sender, pending);  // L110
stakingToken.transfer(msg.sender, amount);   // L113

影响:

  • 如果transfer失败但没有revert,状态已经更新但资金没有实际转移
  • 用户的shares被销毁但Token没有收到
  • 或者用户存入0 Token但获得了shares

推荐修复: 使用OpenZeppelin的SafeERC20:

import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

using SafeERC20 for IERC20;

// 所有transfer改为safeTransfer
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
stakingToken.safeTransfer(msg.sender, pending);

M-02: deposit中rewardDebt覆盖导致已有奖励丢失

严重性: Medium 文件: MomoVault.sol 行号: L94-L95

描述: 当用户多次deposit时,rewardDebt 被直接覆盖:

user.shares += shares;
user.rewardDebt = (user.shares * rewardPerShare) / 1e18; // 覆盖!

如果用户在第一次deposit后累积了一些未领取的奖励,第二次deposit时 rewardDebt 会被重新计算为 user.shares * rewardPerShare / 1e18。但此时 user.shares 已经包含了新增的shares,而 rewardPerShare 没有包含之前未领取的奖励部分。

结果:用户之前累积的未领取奖励被"清零"了。

推荐修复:

function deposit(uint256 amount) external {
    updateRewards();

    UserInfo storage user = userInfo[msg.sender];

    // 先领取已有奖励
    if (user.shares > 0) {
        uint256 pending = (user.shares * rewardPerShare) / 1e18 - user.rewardDebt;
        if (pending > 0) {
            stakingToken.safeTransfer(msg.sender, pending);
            emit RewardClaimed(msg.sender, pending);
        }
    }

    // 计算新份额
    uint256 shares = /* ... */;

    // 更新用户信息
    user.shares += shares;
    user.rewardDebt = (user.shares * rewardPerShare) / 1e18;
    // ...
}

M-03: depositTime每次deposit被覆盖,锁定期可被重置

严重性: Medium 文件: MomoVault.sol 行号: L96

描述:

user.depositTime = block.timestamp; // 每次deposit都重置

当用户多次deposit时,depositTime 被更新为最新的deposit时间。这意味着如果用户在第6天(接近7天解锁期结束时)追加存款,锁定期会被重置,导致用户需要再等7天才能免费取出所有资产(包括之前已经锁定6天的部分)。

这对用户不公平,也可能导致用户不敢追加存款。

推荐修复: 方案A:使用加权平均时间

user.depositTime = (user.shares * user.depositTime + shares * block.timestamp)
                   / (user.shares + shares);

方案B:分别追踪每笔存款

struct DepositInfo {
    uint256 shares;
    uint256 depositTime;
}
mapping(address => DepositInfo[]) public deposits;

L-01: emergencyWithdraw未调用updateRewards

严重性: Low 文件: MomoVault.sol 行号: L158-L170

描述: emergencyWithdraw 没有调用 updateRewards()。虽然emergency withdraw的设计意图是"放弃奖励快速取出",但不调用 updateRewards 意味着:

  • rewardPerShare 没有更新到最新值
  • 其他用户在此之后调用任何函数时才会触发更新
  • 如果在emergencyWithdraw后紧接着有人withdraw或claimReward,他们的奖励计算会基于过时的 rewardPerShare

推荐修复:

function emergencyWithdraw() external {
    updateRewards(); // 添加这行
    UserInfo storage user = userInfo[msg.sender];
    // ...
}

L-02: 缺少admin转移功能和zero address检查

严重性: Low

描述: 合约没有提供admin转移功能。如果admin私钥丢失,setRewardRatedepositRewards 将永远不可调用。constructor中也没有检查 _stakingToken 是否为零地址。


I-01: rewardRate设置缺少上限检查

严重性: Informational

描述: Admin可以设置任意大的 rewardRate。如果误设为极大值,reward = timeElapsed * rewardRate 可能在短时间内产生巨额奖励承诺,远超Vault中实际可用的Token数量。


DeFi常见漏洞速查清单

Vault / 份额类合约

[ ] 首存者攻击(Inflation Attack)
    → 检查:totalShares == 0时的份额计算逻辑
    → 防御:mint minimum shares到dead address

[ ] 份额计算舍入方向
    → deposit时应向下取整(对用户不利 = 对协议有利)
    → withdraw时应向下取整(对用户不利 = 对协议有利)
    → 如果方向相反,可能被利用

[ ] 直接转Token绕过deposit
    → 检查:是否使用balanceOf追踪还是internal变量
    → 如果用balanceOf,外部转入会膨胀份额价值

[ ] 零份额/零金额检查
    → deposit(0) / withdraw(0) 是否有guard
    → shares = 0时是否仍允许操作

奖励分配类合约

[ ] 精度丢失
    → PRECISION是否足够大(建议至少1e18)
    → reward / totalShares 是否可能截断为0

[ ] 奖励来源
    → 合约中是否有足够Token支付承诺的奖励
    → rewardRate * duration 是否 <= 实际可用奖励

[ ] 追加deposit时的奖励结算
    → 用户追加deposit时,之前的pending reward是否被正确处理
    → rewardDebt是否被正确更新

[ ] 奖励claim的重入
    → claimReward中是否先更新状态再转账(CEI模式)

通用DeFi检查

[ ] 闪电贷攻击向量
    → 合约的核心计算是否依赖instantaneous balance
    → 是否可以在同一交易内deposit+withdraw获利

[ ] ERC20兼容性
    → 是否处理了fee-on-transfer Token
    → 是否处理了rebase Token
    → 是否使用SafeERC20

[ ] 紧急功能
    → 是否有pause/emergency withdraw
    → emergency withdraw是否安全(不影响其他用户)

[ ] 时间锁/锁定期
    → 锁定期在追加操作时的行为
    → 时间戳依赖是否安全

关键要点总结

  1. DeFi审计核心关注点:份额计算精度、奖励分配正确性、闪电贷交互、资金充足性
  2. 首存者攻击是Vault的头号威胁:每个Vault合约都必须有防御措施
  3. Accounting一致性:内部记账(totalStaked)和实际余额(balanceOf)必须保持一致
  4. SafeERC20是DeFi合约的标配:不检查transfer返回值是常见且危险的遗漏
  5. 追加操作时的状态处理:用户追加deposit/withdraw时,之前的未结算奖励必须先处理

常见误区

误区1:"我的合约用的是Solidity 0.8.x,不用担心数学问题"

0.8.x防止了溢出,但没有防止精度丢失(1 * 1e18) / 2e18 = 0 在Solidity中完全合法但可能不符合预期。DeFi中几乎所有的数学漏洞都是精度问题,而非溢出问题。

误区2:"我用了internal变量追踪totalStaked,所以不受直接转入影响"

是的,这比用 balanceOf 更安全。但也要注意:如果有fee-on-transfer的Token,transferFrom(user, vault, amount) 后Vault实际收到的可能小于amount,但 totalStaked += amount 却加了完整的amount。

误区3:"Emergency withdraw不需要太多安全检查"

Emergency withdraw恰恰是最需要安全检查的。在紧急情况下(合约被攻击),emergency withdraw可能是用户唯一的救命通道。它必须:(1) 正确更新所有状态 (2) 不依赖可能被操纵的值 (3) 即使在异常状态下也能工作。


面试关联

Q1: "DeFi Vault合约中最常见的安全问题是什么?"

回答: 最常见的三个问题是:(1) 首存者攻击/Inflation Attack——攻击者通过操纵首次存款的份额比例来窃取后续用户资金;(2) 份额计算精度问题——整数除法舍入可能导致用户获得0 shares;(3) 奖励分配不足——合约承诺的奖励超过实际可用余额,导致最后的用户无法提取。

Q2: "如何防御首存者攻击?"

回答: 主要有三种方式:(1) 像OpenZeppelin ERC4626那样,在首次存款时额外铸造一定数量的shares到dead address,让攻击成本大幅增加;(2) 在份额计算中添加虚拟偏移(virtual offset),使得即使totalShares很小,计算结果也不会被极端操纵;(3) 设置最小首次存款量,确保初始shares足够大。

Q3: "审计一个DeFi协议和审计一个Token合约有什么区别?"

回答: Token合约审计主要关注权限控制和标准合规。DeFi审计额外需要关注:(1) 经济模型正确性——份额、利率、奖励的数学公式是否正确;(2) 跨合约交互——与预言机、其他DeFi协议的组合是否安全;(3) 闪电贷攻击面——合约是否能在同一交易内被利用;(4) 会计一致性——内部记账和实际余额是否匹配。


参考资源

资源说明
ERC-4626 Standard标准化Vault接口
OpenZeppelin ERC4626参考实现(含首存者攻击防御)
First Depositor Attack ExplainedMixBytes对Inflation Attack的详解
DeFi Security Best Practices - NascentDeFi安全工具包
SWC Registry智能合约弱点分类
Solodit审计发现数据库,搜索真实案例