reserveFactor
)协议费是 DeFi 协议获取收入、支持开发和治理的核心手段。常见来源有:
reserveFactor
或 protocol fee)进入金库(Treasury)。 核心职能:
常见治理与资金流向:
金库的安全设计要点:
实现上要回答两个问题:什么时候把收益算到金库? 和 如何记账?
reserves
变量。优势:会计直观;劣势:每次写入增加 gas。harvest()
将收益转入 Treasury。优势:节省 gas(把多笔转账合并);劣势:需要 keeper 和激励。withdrawReserves()
驱动。适合高并发、规模化的协议。记账字段示例:
uint256 totalReserves
:累计到 Treasury 的总额(on-chain accounting)mapping(address => uint256) accruedFees
:按资产/市场拆分(对 multi-asset 协议)treasuryAddress
:实际托管资金地址(通常为多签或 timelock)治理是把对协议参数、金库资金使用、升级、紧急措施等决策权下放给社区/代币持有者。主要构件:
ERC20Votes
)或锁仓模型(veToken
)。 实现一个能被测试的最小链路:
reserveFactor
的份额记入 reserves
(on-accrual)。 reserveFactor
并从 Treasury 提取资金(需 timelock 后执行)。下面代码只是个安全简化版本,去掉复杂的 OpenZeppelin Governor 依赖,使用一个非常简化的治理模型(propose -> queue -> execute
,用简单投票模拟或由测试直接触发投票通过)。重点验证 费用流 & Timelock 执行。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/**
* @title Treasury Interface
* @notice Interface for Treasury contract that collects protocol fees
*/
interface ITreasury {
/**
* @notice Collect protocol fees from lending pool
* @param token The token address to collect
* @param amount The amount of tokens to collect
*/
function collect(address token, uint256 amount) external;
}
/**
* @title Lending Pool With Fees
* @notice A lending pool that collects protocol fees on interest payments
* @dev Implements basic deposit/borrow functionality with protocol fee collection
*/
contract LendingPoolWithFees {
using SafeERC20 for IERC20;
/// @notice The underlying asset token
IERC20 public immutable asset;
/// @notice The treasury contract for fee collection
ITreasury public treasury;
/// @notice Total amount deposited in the pool
uint256 public totalDeposits;
/// @notice Total amount borrowed from the pool
uint256 public totalBorrows;
/**
* @notice Reserve factor in basis points (0..10000 = 0%..100%)
* @dev 1000 = 10% of interest goes to treasury
*/
uint256 public reserveFactorBps = 1000;
/// @notice Basis points denominator (10000 = 100%)
uint256 public constant BPS = 10000;
/// @notice Mapping of user addresses to their deposit amounts
mapping(address => uint256) public deposits;
/// @notice Mapping of user addresses to their borrow amounts
mapping(address => uint256) public borrows;
/**
* @notice Emitted when a user deposits assets
* @param user The address of the depositor
* @param amt The amount deposited
*/
event Deposit(address indexed user, uint256 amt);
/**
* @notice Emitted when a user borrows assets
* @param user The address of the borrower
* @param amt The amount borrowed
*/
event Borrow(address indexed user, uint256 amt);
/**
* @notice Emitted when interest is paid
* @param user The address paying interest
* @param interest The total interest amount paid
* @param reservePortion The portion of interest sent to treasury
*/
event InterestPaid(address indexed user, uint256 interest, uint256 reservePortion);
/**
* @notice Initialize the lending pool
* @param _asset The ERC20 token used as underlying asset
* @param _treasury The treasury contract for fee collection
*/
constructor(IERC20 _asset, ITreasury _treasury) {
asset = _asset;
treasury = _treasury;
}
/**
* @notice Deposit assets into the lending pool
* @param amount The amount of assets to deposit
* @dev Transfers tokens from sender to contract and updates deposit balances
*/
function deposit(uint256 amount) external {
asset.safeTransferFrom(msg.sender, address(this), amount);
deposits[msg.sender] += amount;
totalDeposits += amount;
emit Deposit(msg.sender, amount);
}
/**
* @notice Borrow assets from the lending pool
* @param amount The amount of assets to borrow
* @dev Checks available liquidity and transfers tokens to borrower
*/
function borrow(uint256 amount) external {
require(totalDeposits - totalBorrows >= amount, "no liquidity");
borrows[msg.sender] += amount;
totalBorrows += amount;
asset.safeTransfer(msg.sender, amount);
emit Borrow(msg.sender, amount);
}
/**
* @notice Pay interest on borrowed assets
* @param interestAmount The amount of interest to pay
* @dev Splits interest between protocol treasury and lenders based on reserve factor
*/
function payInterest(uint256 interestAmount) external {
require(borrows[msg.sender] > 0, "no debt");
asset.safeTransferFrom(msg.sender, address(this), interestAmount);
// Calculate protocol's share of interest
uint256 reservePortion = (interestAmount * reserveFactorBps) / BPS;
uint256 toLenders = interestAmount - reservePortion;
// Transfer protocol share to treasury
asset.approve(address(treasury), reservePortion);
treasury.collect(address(asset), reservePortion);
// Distribute remaining interest to lenders (simplified - just add to total deposits)
totalDeposits += toLenders;
emit InterestPaid(msg.sender, interestAmount, reservePortion);
}
/**
* @notice Update the reserve factor
* @param newBps New reserve factor in basis points (0-10000)
* @dev In production, this should be restricted to governance only
*/
function setReserveFactor(uint256 newBps) external {
// In real implementation: require(msg.sender == governance, "only governance");
require(newBps <= BPS, "invalid bps");
reserveFactorBps = newBps;
}
}
说明:
payInterest
模拟利息支付并把 reserveFactor
的份额通过 treasury.collect()
转到 Treasury(Treasure 接口将做转账处理)。setReserveFactor
在真实合约里应由治理合约通过 timelock 调用;示例保留简化接口以便测试展示治理流程。// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/**
* @title Protocol Treasury
* @notice Manages protocol fee collection and disbursement
* @dev Funds can only be withdrawn by timelock or guardian for security
*/
contract Treasury {
using SafeERC20 for IERC20;
/// @notice Address with authority to approve fund withdrawals (governance/timelock)
address public timelock;
/// @notice Emergency guardian address for recovery scenarios
address public guardian;
/**
* @notice Emitted when fees are collected from protocol contracts
* @param token The token that was collected
* @param amount The amount collected
*/
event Collected(address indexed token, uint256 amount);
/**
* @notice Emitted when funds are withdrawn from treasury
* @param to The recipient address
* @param token The token withdrawn
* @param amount The amount withdrawn
*/
event Withdrawn(address indexed to, address indexed token, uint256 amount);
/**
* @notice Initialize the treasury contract
* @param _timelock The timelock contract address (governance)
* @param _guardian The guardian address for emergency operations
*/
constructor(address _timelock, address _guardian) {
timelock = _timelock;
guardian = _guardian;
}
/**
* @notice Collect protocol fees from lending pools or other contracts
* @param token The token address to collect
* @param amount The amount of tokens to collect
* @dev Caller must have approved this contract to spend the tokens
*/
function collect(address token, uint256 amount) external {
// Transfer tokens from the calling contract to treasury
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
emit Collected(token, amount);
}
/**
* @notice Withdraw funds from treasury
* @param to The recipient address
* @param token The token to withdraw
* @param amount The amount to withdraw
* @dev Only callable by timelock or guardian
*/
function withdraw(address to, address token, uint256 amount) external {
require(msg.sender == timelock || msg.sender == guardian, "not allowed");
IERC20(token).safeTransfer(to, amount);
emit Withdrawn(to, token, amount);
}
/**
* @notice Update the timelock address
* @param _timelock The new timelock address
* @dev Only callable by current guardian or timelock
*/
function setTimelock(address _timelock) external {
require(msg.sender == guardian || msg.sender == timelock, "not allowed");
timelock = _timelock;
}
}
说明:
collect
:协议把已批准的 token 从协议合约转入 Treasury。这个简单流程保证资金在链上并可被治理控制。withdraw
:仅允许 timelock
(代表治理)或 guardian
(紧急权限)调用支出。// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title Timelock Stub Contract
* @notice Simplified timelock for testing governance operations
* @dev This is a minimal implementation for testing, not production use
*/
contract TimelockStub {
/// @notice Administrator address with queueing privileges
address public admin;
/// @notice Minimum delay before queued transactions can execute
uint256 public delay; // seconds
/**
* @notice Transaction data structure
* @param target The target contract address
* @param data The calldata to execute
* @param value The ETH value to send
* @param eta The timestamp after which transaction can execute
* @param executed Whether the transaction has been executed
*/
struct Tx {
address target;
bytes data;
uint256 value;
uint256 eta; // execute after
bool executed;
}
/// @notice Array of queued transactions
Tx[] public queued;
/**
* @notice Emitted when a transaction is queued
* @param txId The transaction ID in the queue
* @param target The target contract address
* @param eta The execution timestamp
*/
event QueueTx(uint256 indexed txId, address target, uint256 eta);
/**
* @notice Emitted when a transaction is executed
* @param txId The transaction ID that was executed
*/
event ExecuteTx(uint256 indexed txId);
/**
* @notice Initialize the timelock
* @param _admin The administrator address
* @param _delay The minimum execution delay in seconds
*/
constructor(address _admin, uint256 _delay) {
admin = _admin;
delay = _delay;
}
/**
* @notice Queue a transaction for future execution
* @param target The target contract address
* @param data The calldata to execute
* @param value The ETH value to send
* @param eta The timestamp after which transaction can execute
* @dev Only callable by admin
*/
function queue(address target, bytes calldata data, uint256 value, uint256 eta) external {
require(msg.sender == admin, "not admin");
queued.push(Tx({target: target, data: data, value: value, eta: eta, executed: false}));
emit QueueTx(queued.length - 1, target, eta);
}
/**
* @notice Execute a queued transaction
* @param txId The transaction ID to execute
* @dev Transaction must be past its execution timestamp
*/
function execute(uint256 txId) external payable {
Tx storage t = queued[txId];
require(!t.executed, "executed");
require(block.timestamp >= t.eta, "too early");
(bool ok, ) = t.target.call{value: t.value}(t.data);
require(ok, "call failed");
t.executed = true;
emit ExecuteTx(txId);
}
/**
* @notice Get the number of queued transactions
* @return The length of the queued transactions array
*/
function queuedLength() external view returns (uint256) {
return queued.length;
}
}
说明:
eta
之后可执行,模拟真实 timelock 的时延保护。ProtocolFeesAndGovernance.t.sol
,测试以下功能:
reserveFactor
的份额最终被 Treasury
收到。reserveFactor
并在 timelock 到期后执行,从而验证治理流程影响协议参数。withdraw
需由 Timelock 执行(安全检查)。// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/LendingPoolWithFees.sol";
import "../src/Treasury.sol";
import "../src/TimelockStub.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @title Mock ERC20 Token
* @notice Test token for protocol testing
*/
contract MockToken is ERC20 {
/**
* @notice Initialize mock token with initial supply
*/
constructor() ERC20("MockUSD", "mUSD") {
_mint(msg.sender, 1_000_000 ether);
}
/**
* @notice Mint tokens to specified address
* @param to The recipient address
* @param amt The amount to mint
*/
function mintTo(address to, uint256 amt) external {
_mint(to, amt);
}
}
/**
* @title Protocol Fees and Governance Test Suite
* @notice Comprehensive tests for lending pool fee mechanism and governance operations
*/
contract ProtocolFeesAndGovernanceTest is Test {
MockToken token;
Treasury treasury;
TimelockStub timelock;
LendingPoolWithFees pool;
address deployer = address(this); // test contract acts as deployer/admin
address alice = address(0x1);
address bob = address(0x2);
/**
* @notice Set up test environment
* @dev Deploys all contracts and funds test accounts
*/
function setUp() public {
token = new MockToken();
// deploy timelock with admin = address(this)
timelock = new TimelockStub(address(this), 1 days);
// deploy treasury with timelock as controller
treasury = new Treasury(address(timelock), address(this));
pool = new LendingPoolWithFees(IERC20(address(token)), ITreasury(address(treasury)));
// fund alice/bob
token.mintTo(alice, 1000 ether);
token.mintTo(bob, 1000 ether);
vm.startPrank(alice);
token.approve(address(pool), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
token.approve(address(pool), type(uint256).max);
vm.stopPrank();
}
/**
* @notice Test protocol fee collection mechanism
* @dev Verifies that interest payments are correctly split between treasury and lenders
*/
function testReserveAccrualAndTreasuryCollect() public {
// Alice deposits 100
vm.prank(alice);
pool.deposit(100 ether);
// Bob borrows 50
vm.prank(bob);
pool.borrow(50 ether);
// Bob pays interest = 10
vm.prank(bob);
token.mintTo(bob, 10 ether);
token.approve(address(pool), 10 ether);
vm.prank(bob);
pool.payInterest(10 ether);
// reserveFactor default 10% (1000 bps) => reservePortion = 1
// Treasury should have received 1 token
assertEq(token.balanceOf(address(treasury)), 1 ether);
}
/**
* @notice Test governance parameter update via timelock
* @dev Verifies reserve factor can be updated through governance process
*/
function testGovernanceChangeReserveFactorViaTimelock() public {
// initial reserveFactor
assertEq(pool.reserveFactorBps(), 1000);
// prepare calldata to call pool.setReserveFactor(2000)
bytes memory data = abi.encodeWithSelector(LendingPoolWithFees.setReserveFactor.selector, uint256(2000));
// queue via timelock (admin = this test contract)
uint256 eta = block.timestamp + 1 days + 1;
timelock.queue(address(pool), data, 0, eta);
// fast-forward to eta
vm.warp(eta + 1);
// execute via timelock (timelock will call pool.setReserveFactor(2000))
timelock.execute(0);
// verify change
assertEq(pool.reserveFactorBps(), 2000);
}
/**
* @notice Test treasury withdrawal access control
* @dev Verifies only timelock can withdraw funds from treasury
*/
function testTreasuryWithdrawRequiresTimelock() public {
// Collect some funds first
vm.prank(bob);
pool.deposit(100 ether);
vm.prank(bob);
pool.borrow(50 ether);
vm.prank(bob);
token.mintTo(bob, 10 ether);
vm.prank(bob);
token.approve(address(pool), 10 ether);
vm.prank(bob);
pool.payInterest(10 ether);
// Treasury has 1 token
assertEq(token.balanceOf(address(treasury)), 1 ether);
// Direct withdraw by non-timelock should fail
vm.prank(alice);
vm.expectRevert();
treasury.withdraw(alice, address(token), 1 ether);
// Queue withdraw via timelock: call treasury.withdraw(alice, token, 1)
bytes memory data = abi.encodeWithSelector(Treasury.withdraw.selector, alice, address(token), 1 ether);
uint256 eta = block.timestamp + 1 days + 1;
timelock.queue(address(treasury), data, 0, eta);
// warp and execute
vm.warp(eta + 1);
timelock.execute(0);
// Alice started with 1000 ether and received 1 ether from treasury
assertEq(token.balanceOf(alice), 1001 ether);
}
}
执行测试:
➜ defi git:(master) ✗ forge test --match-path test/ProtocolFeesAndGovernance.t.sol -vvv
[⠊] Compiling...
[⠔] Compiling 4 files with Solc 0.8.29
[⠒] Solc 0.8.29 finished in 1.44s
Compiler run successful!
Ran 3 tests for test/ProtocolFeesAndGovernance.t.sol:ProtocolFeesAndGovernanceTest
[PASS] testGovernanceChangeReserveFactorViaTimelock() (gas: 190634)
[PASS] testReserveAccrualAndTreasuryCollect() (gas: 252323)
[PASS] testTreasuryWithdrawRequiresTimelock() (gas: 448244)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 6.94ms (4.58ms CPU time)
Ran 1 test suite in 466.02ms (6.94ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
LendingPoolWithFees
的 payInterest
改为按时间自动累积利息(结合第 3 课的 index),并确保 reserveFactor
的份额正确计入 Treasury(即把 periodic 收益转成 on-chain collect)。TimelockStub
增加 cancel
功能(允许 admin 在执行前取消排队),并写测试验证。原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。