项目代码:https://github.com/langjiyunmie/Defi-stablecoin 观看视频:https://www.bilibili.com/video/BV13a4y1F7V3?spm_id_from=333.788.videopod.episodes&vd_source=
我们这里采用超额抵押的算法机制,来coding我们的算法稳定币dsc。此项目只限于学习,其本身的算法机制并不完善。
代币合约。这里我们的dsc代币对标ustd,1美元锚定为标准。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {ERC20Burnable, ERC20} from "lib/openzepplin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import { Ownable } from "lib/openzepplin-contracts/contracts/access/Ownable.sol";
// 在 OpenZeppelin 合约包的未来版本中,必须使用合约所有者的地址声明 Ownable
// 作为参数。
// 例如:
// constructor() ERC20(“去中心化稳定币”, “DSC”) ownable(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) {}
contract DecentralizedStableCoin is ERC20Burnable, Ownable {
error DecentralizedStableCoin__AmountMustBeGreaterThanZero();
error DecentralizedStableCoin__BurnAmountExceedsBalance();
error DecentralizedStableCoin__CannotMintToZeroAddress();
event Burned(address indexed from, uint256 amount);
event Minted(address indexed to, uint256 amount);
constructor() ERC20("DecentralizedStableCoin", "DSC") {}
function burn(uint256 _amount) public override onlyOwner {
uint256 balance = balanceOf(msg.sender);
if(_amount < 0 ){
revert DecentralizedStableCoin__AmountMustBeGreaterThanZero();
}
if(balance < _amount){
revert DecentralizedStableCoin__BurnAmountExceedsBalance();
}
super.burn(_amount);
emit Burned(msg.sender, _amount);
}
function mint(address _to, uint256 _amount) public onlyOwner {
if(_amount <= 0){
revert DecentralizedStableCoin__AmountMustBeGreaterThanZero();
}
if(_to == address(0)){
revert DecentralizedStableCoin__CannotMintToZeroAddress();
}
_mint(_to, _amount);
emit Minted(_to, _amount);
}
}
openzeppelin中的Ownable合约
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts v4.4.1 (access/Ownable.sol)
pragma solidity ^0.8.0;
import "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* By default, the owner account will be the one that deploys the contract. This
* can later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev Initializes the contract setting the deployer as the initial owner.
*/
constructor() {
_transferOwnership(_msgSender());
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(owner() == _msgSender(), "Ownable: caller is not the owner");
_;
}
/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions anymore. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby removing any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
_transferOwnership(newOwner);
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
这里设置ownable用到了继承的context合约的方法
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts v4.4.1 (utils/Context.sol)
pragma solidity ^0.8.0;
/**
* @dev Provides information about the current execution context, including the
* sender of the transaction and its data. While these are generally available
* via msg.sender and msg.data, they should not be accessed in such a direct
* manner, since when dealing with meta-transactions the account sending and
* paying for execution may not be the actual sender (as far as an application
* is concerned).
*
* This contract is only required for intermediate, library-like contracts.
*/
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
_msgSender() 方法在我们继承 Ownable 合约的时候,自动进行了调用,在 OpenZeppelin 合约包的未来版本中,必须使用合约所有者的地址声明 Ownable 作为参数。 例如:
constructor() ERC20(“去中心化稳定币”, “DSC”) ownable(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) {}
关于openzeppelin中的ERC20Burnable合约
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC20/extensions/ERC20Burnable.sol)
pragma solidity ^0.8.0;
import "../ERC20.sol";
import "../../../utils/Context.sol";
/**
* @dev Extension of {ERC20} that allows token holders to destroy both their own
* tokens and those that they have an allowance for, in a way that can be
* recognized off-chain (via event analysis).
*/
abstract contract ERC20Burnable is Context, ERC20 {
/**
* @dev Destroys `amount` tokens from the caller.
*
* See {ERC20-_burn}.
*/
function burn(uint256 amount) public virtual {
_burn(_msgSender(), amount);
}
/**
* @dev Destroys `amount` tokens from `account`, deducting from the caller's
* allowance.
*
* See {ERC20-_burn} and {ERC20-allowance}.
*
* Requirements:
*
* - the caller must have allowance for ``accounts``'s tokens of at least
* `amount`.
*/
function burnFrom(address account, uint256 amount) public virtual {
_spendAllowance(account, _msgSender(), amount);
_burn(account, amount);
}
}
这个合约是整个项目的核心。我们的项目是做一个提供质押铸造的稳定币,用户可以通过质押eth来获得 dsc 这个代币,其他用户可以清算达到清算阈值的资产。
先完善质押兑换这一个核心功能。
由于各个质押产品的价格不同,支持的token也就不一样,所以一开始我们应该要有一个白名单记录我们支持的质押代币,同时记录对应的价格源,因此也需要将两个参数绑定
// 获取抵押品的实时价格
mapping(address collateralToken => address priceFeed) public priceFeeds;
// 构造函数,初始化抵押品和价格源
constructor(address[] memory tokenAddresses, address[] memory priceFeedAddresses, address dscAddress) {
if(tokenAddresses.length != priceFeedAddresses.length){
revert DSCEngine__TokenAddressesAndPriceFeedAddressesLengthsMustBeTheSame();
}
for(uint256 i = 0; i < tokenAddresses.length; i++){
if(tokenAddresses[i] == address(0)){
revert DSCEngine__TokenAddressesAndPriceFeedAddressesLengthsMustBeTheSame();
}
priceFeeds[tokenAddresses[i]] = priceFeedAddresses[i];
_collateralTokens.push(tokenAddresses[i]);
}
i_dsc = DecentralizedStableCoin(dscAddress);
从这里,获取了价格源,需要预言机去对应的价格源去获取价格因此需要一个datafeed合约来完成这件事情。
获取质押代币的实时价格
AggregatorV3Interface接口
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// solhint-disable-next-line interface-starts-with-i
interface AggregatorV3Interface {
function decimals() external view returns (uint8);
function description() external view returns (string memory);
function version() external view returns (uint256);
function getRoundData(
uint80 _roundId
) external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
function latestRoundData()
external
view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
}
roundId
(uint80
)roundId
。这个 roundId
用于标识这是第几轮价格更新或报告。roundId
,你可以知道当前的价格数据是哪一轮生成的。answer
(int256
)answer
通常是某种资产的价格,例如 ETH/USD 或 BTC/USD 的价格。int256
是因为价格可能为负数(尽管在实际使用中很少见)。例如,它可以用于某些负值的经济数据。startedAt
(uint256
)startedAt
代表这一轮价格数据采集的开始时间,通常是 UNIX 时间戳(即从1970年1月1日以来的秒数)。updatedAt
(uint256
)updatedAt
代表预言机在这一轮价格更新的确切时间,也是 UNIX 时间戳格式。answeredInRound
(uint80
)answeredInRound
小于 roundId
,则表明当前轮次的结果还没有最终确定或回答可能是来自于前几轮。answer
是在哪一轮被有效报告的,这可以帮助你验证数据的准确性。根据接口,我们实例化一个接口对象来调用这些函数,获取我们需要的值
AggregatorV3Interface chainlinkPriceFeed
合约代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
library OracleLib {
// 检查价格是否过时
error OracleLib__StalePrice();
uint256 private constant TIMEOUT = 3 hours;
// 检查价格是否过时
function staleCheckLatestRoundData(AggregatorV3Interface chainlinkPriceFeed)
public view returns (
uint80,
int256,
uint256,
uint256,
uint80) {
(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = chainlinkPriceFeed.latestRoundData();
if(updatedAt == 0 || answeredInRound < roundId){
revert OracleLib__StalePrice();
}
uint256 secondsSince = block.timestamp - updatedAt;
if(secondsSince > TIMEOUT){
revert OracleLib__StalePrice();
}
return (roundId, answer, startedAt, updatedAt, answeredInRound);
}
function getTimeout(AggregatorV3Interface /*chainlinkPriceFeed*/) public pure returns (uint256) {
return TIMEOUT;
}
}
其中有两个要注意的地方
预言机返回的值是根据 ustd 这一个稳定币返回的值,所以其精度是 1e8 而不是 1e18
1e18 对应的单位是 1 wei
if(updatedAt == 0 || answeredInRound < roundId)
updatedAt == 0 检查价格更新时间是否为0,如果是0,表示这个价格数据从未被更新过,这种情况通常意味着预言机可能出现了问题
answeredInRound < roundId 实际回答价格的轮次ID,如果 answeredInRound 小于 roundId,表示使用了旧轮次的数据来回答当前轮次,这种情况可能意味着价格数据已经过时或者预言机网络出现了问题
获取价格之后,我们就可以根据用户质押的资产,以 USTD 为最小单位来预估用户资产,以此来铸造出对应价值的 dsc 代币
所以我们需要记录用户质押了多少资产以及其资产的预估价值。同时计算给定USD金额需要多少代币
/**
* @notice 计算给定USD金额需要多少代币
* @dev 使用Chainlink预言机获取代币价格,然后进行计算
* 例如:要借100 USD,ETH价格是2000 USD,则需要0.05 ETH
* @param tokenCollateralAddress 代币地址
* @param usdAmountIn USD金额(18位精度)
* @return 需要的代币数量(以代币精度为单位)
*/
function getTokenAmountFromUsd(address tokenCollateralAddress, uint256 usdAmountIn) public view returns (uint256) {
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeeds[tokenCollateralAddress]);
(, int256 price,,,) = priceFeed.latestRoundData();
return (usdAmountIn * _PRECISION) / (uint256(price) * _ADDITIONAL_FEED_PRECISION);
}
质押资产有两步操作
记录用户质押资产的数量
将用户的代币转入到当前合约中
// 记录用户抵押品数量
mapping(address user => mapping(address collateralToken => uint256 amount)) private _collateralDeposited;
// 质押用户资产
function despositCollateral(address tokenCollateralAddress
, uint256 amountCollateral) public {
_collateralDeposited[msg.sender][tokenCollateralAddress] += amountCollateral;
emit CollateralDeposited(msg.sender, tokenCollateralAddress, amountCollateral);
bool success = IERC20(tokenCollateralAddress).transferFrom(msg.sender, address(this), amountCollateral);
if(!success){
revert DSCEngine__TransferFailed();
}
质押完成,用户可以根据自身情况来铸造对应数量的dsc代币那么这里又涉及到了一个问题,我们知道代币的价格是有波动性的,作为不是稳定币的资产,今天的估值跟几个月后的又有所不同。用户可以铸造多少dsc代币?那么我们就需要一个功能来确定一件事情,如果ETH今天值 2000u,未来跌到1000u,那么他之前所铸造的dsc代币又该怎么处理?对于已经拥有了dsc代币这种情况,我们不可能说让他又还一部分回来,那么就只有一个选择,对他质押的资产进行清算。对于这两个问题,这里需要一个健康值,来确定他可以铸造多少dsc代币以及到多少价值的时候会被清算
给出一个标准,当 健康因子 >= 1 时,用户资产不会被清算,健康因子 < 1 时将由其他用户来清算质押的资产
* 健康因子 = 抵押品总价值 / 铸造的DSC数量
那么我们首先就要知道抵押品的总价值是多少
我们需要确定两个个信息
首先最先要确定的是原生代币(ETH) 价格,相对于 ustd 值多少 wei ,这里涉及到了精度转换
// 添加抵押品精度
uint256 private constant _ADDITIONAL_FEED_PRECISION = 1e10;
function _getUsdValue(address token, uint256 amount) private view returns(uint256) {
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeeds[token]);
(, int256 price,,,) = priceFeed.latestRoundData();
// price 返回的是1e8的精度
return ((uint256(price) * _ADDITIONAL_FEED_PRECISION * amount) / _PRECISION);
}
接着通过mapping,遍历用户拥有的token以及数量,并进行价值转换
/**
* @notice 获取用户所有抵押品的总价值(以USD计)
* @dev 遍历用户的所有抵押品,计算它们的总价值
* @param user 要查询的用户地址
* @return totalCollateralValue 用户所有抵押品的总价值(以USD计,18位精度)
*/
function getAccountCollateralValue(address user) public view returns (uint256 totalCollateralValue) {
for (uint256 index = 0; index < _collateralTokens.length; index++) {
address token = _collateralTokens[index];
uint256 amount = _collateralDeposited[user][token];
totalCollateralValue += _getUsdValue(token, amount);
}
return totalCollateralValue;
}
确定清算阈值,这里以 50% 为参数
// 清算阈值
uint256 private constant _LIQUIDATION_THRESHOLD = 50;
// 清算精度
uint256 private constant _LIQUIDATION_PRECISION = 100;
/**
* @notice 获取账户健康因子
* @dev 返回用户账户的健康状况
* 健康因子 = 抵押品总价值 / 铸造的DSC数量
* @return 健康因子,用uint256表示
*/
function _calculateHealthFactor(
uint256 totalDscMinted,
uint256 totalCollateralValue) internal pure returns (uint256) {
if(totalDscMinted == 0){
return type(uint256).max;// 如果DSC铸造为0,则健康因子为最大值。同时保证下面不会除以 0
}
uint256 collateralAdjustedForThreshold = (totalCollateralValue * _LIQUIDATION_THRESHOLD) / _LIQUIDATION_PRECISION;
return (collateralAdjustedForThreshold * _PRECISION) / totalDscMinted;
}
function calculateHealthFactor(
uint256 totalDscMinted,
uint256 totalCollateralValue) public pure returns (uint256) {
return _calculateHealthFactor(totalDscMinted, totalCollateralValue);
}
现在有个问题,我们只是可以计算健康因子,但并没有跟用户进行绑定,这里用mapping进行绑定是不现实的,因为这个值并没有独立性。我们就另建函数,传入 address user 参数。
function _getAccountInformation(address user) private view returns (uint256 totalDscMinted, uint256 totalCollateralValue){
totalDscMinted = _dscMinted[user];
totalCollateralValue = getAccountCollateralValue(user);
}
/**
* @notice 获取用户账户信息
* @dev 返回用户铸造的DSC数量和所有抵押品总价值
* @param user 用户地址
* @return totalDscMinted 用户铸造的DSC数量
* @return totalCollateralValue 用户所有抵押品总价值
*/
function getAccountInformation(address user) public view returns (uint256 totalDscMinted, uint256 totalCollateralValue){
(totalDscMinted, totalCollateralValue) = _getAccountInformation(user);
}
将健康因子跟用户进行绑定后,用户可以查看他们资产的健康状况。当健康因子正常,用户可以mint dsc代币,处于危险状况时,则不能进行mint 操作。我们需要一个函数来判断当前的健康状况
function _revertIfHealthFactorIsBroken(address user) internal view {
uint256 healthFactor = _healthFactor(user);
if(healthFactor < _MIN_HEALTH_FACTOR){
revert DSCEngine__HealthFactorIsBroken();
}
}
这样,合约就可以放心的把mint交给用户了
/**
* @notice 铸造DSC代币
* @dev 用户可以基于已存入的抵押品铸造DSC
* 需要确保铸造后维持健康的抵押率
*/
function mintDsc(uint256 amountDscToMint) public moreThanZero(amountDscToMint) {
_dscMinted[msg.sender] += amountDscToMint;
_revertIfHealthFactorIsBroken(msg.sender);
bool minted = i_dsc.mint(msg.sender, amountDscToMint);
if(!minted){
revert DSCEngine__MintFailed();
}
}
赎回功能是必须的。这里有两种情况
很简单,先进行transfer操作,之后检查健康因子
function redeemCollateral(address tokenCollateralAddress, uint256 amountCollateral) external {
_redeemCollateral(tokenCollateralAddress, amountCollateral, msg.sender, msg.sender);
_revertIfHealthFactorIsBroken(msg.sender);
}
/**
* @notice 赎回抵押品
* @dev 允许用户取回他们的抵押品
* 需要确保赎回后维持足够的抵押率
*/
function _redeemCollateral(
address tokenCollateralAddress,
uint256 amountCollateral,
address from,
address to
) private {
_collateralDeposited[from][tokenCollateralAddress] -= amountCollateral;
emit CollateralRedeemed(from, tokenCollateralAddress, amountCollateral);
bool success = IERC20(tokenCollateralAddress).transfer(to, amountCollateral);
if(!success){
revert DSCEngine__TransferFailed();
}
}
ERC20中的transfer函数是以 msg.sender 参数进行操作的
/**
* @dev See {IERC20-transfer}.
*
* Requirements:
*
* - `to` cannot be the zero address.
* - the caller must have a balance of at least `amount`.
*/
function transfer(address to, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_transfer(owner, to, amount);
return true;
}
burnDsc函数
这里有一点要注意,进行任何资金操作,由合约代理执行,都是需要授权的,除非使用本人操作,或者将资产转移到合约中,由合约进行操作。
所以这里并不能直接使用 i_dsc.burn(amountDscToBurn),burn函数也跟transfer一样,操作这是msg.sender。需要先将dsc代币转移给当前合约
/**
* @notice 销毁DSC代币
* @dev 用户可以销毁自己持有的DSC
* 通常用于减少债务或准备赎回抵押品
*/
function _burnDsc(uint256 amountDscToBurn,address onBehalfOf, address dscFrom) private {
_dscMinted[onBehalfOf] -= amountDscToBurn;
bool success = i_dsc.transferFrom(dscFrom, address(this), amountDscToBurn);
if(!success){
revert DSCEngine__BurnFailed();
}
i_dsc.burn(amountDscToBurn);
}
这里为什么需要传入两个地址呢?因为后面其他用户对该用户进行清算,想要以dsc代币获取该用户原生代币的时候,又要使用到burn函数,至于赎回的操作,我们传入两个msg.sender就行了。
function redeemCollateralForDsc(address tokenCollateralAddress, uint256 amountDscToBurn,uint256 amountCollateralToRedeem) external {
_burnDsc(amountDscToBurn, msg.sender, msg.sender);
_redeemCollateral(tokenCollateralAddress, amountCollateralToRedeem, msg.sender, msg.sender);
_revertIfHealthFactorIsBroken(msg.sender);
}
这里为了鼓励其他用户去清算,使用了奖励机制,这里的 bonus 设置为 10%
// 清算奖励
uint256 private constant _LIQUIDATION_BONUS = 10;
function liquidate(address collateral, address user, uint256 debtToCover) external {
uint256 startingUserHealthFactor = _healthFactor(user);
if(startingUserHealthFactor > _MIN_HEALTH_FACTOR){
revert DSCEngine__HealthFactorIsNotBroken();
}
uint256 tokenAmountFromDebtCovered = getTokenAmountFromUsd(collateral, debtToCover);
uint256 bonusCollateral = (tokenAmountFromDebtCovered * _LIQUIDATION_BONUS) / _LIQUIDATION_PRECISION;
// 赎回抵押品
_redeemCollateral(collateral, tokenAmountFromDebtCovered + bonusCollateral, user, msg.sender);
_burnDsc(debtToCover, user, msg.sender);
// 检查清算后的健康因子
uint256 endingUserHealthFactor = _healthFactor(user);
if(endingUserHealthFactor <= _MIN_HEALTH_FACTOR){
revert DSCEngine__HealthFactorIsBroken();
}
_revertIfHealthFactorIsBroken(msg.sender);
}
其实关于清算机制,真正用于实践的话,是不行的。涉及到市场代币波动,以及清算活跃度的问题。就拿市场波动来说,如果一个代币的价格波动过大,比如比特币今天10w u,明天雪崩到 5w u了,在这种巨大的波动下,如果没有人即使的去清算资产,会产生资不抵债的问题。形象地假设抵押率是 100%
初始状态:
- 用户抵押了 1000 美元的 ETH
- 借出了 1000 DSC
当 ETH 价格瞬间下跌 20% 时:
- ETH 抵押品现在只值 800 美元
- 但仍有 1000 DSC 的债务
要保证三个方面
// 检查数量是否大于0
modifier moreThanZero(uint256 value){
if(value <= 0){
revert DSCEngine__MoreThanZero();
}
_;
// 检查抵押品是否被允许
modifier isAllowedToken(address tokenAddress){
if(priceFeeds[tokenAddress] == address(0)){
revert DSCEngine__TokenAddressesAndPriceFeedAddressesLengthsMustBeTheSame();
}
_;
}
重入锁直接使用openzepplin的代码库
contract DSCEngine is ReentrancyGuard
接着就是完善各个函数了,添加到各个函数的后面。
便于获取数据,方便后续数据的对接
function getCollateralBalanceOfUser(address user, address tokenCollateralAddress) public view returns (uint256) {
return _collateralDeposited[user][tokenCollateralAddress];
}
function getPrecision() external pure returns (uint256) {
return _PRECISION;
}
function getAdditionalFeedPrecision() external pure returns (uint256) {
return _ADDITIONAL_FEED_PRECISION;
}
function getLiquidationThreshold() external pure returns (uint256) {
return _LIQUIDATION_THRESHOLD;
}
function getLiquidationBonus() external pure returns (uint256) {
return _LIQUIDATION_BONUS;
}
function getLiquidationPrecision() external pure returns (uint256) {
return _LIQUIDATION_PRECISION;
}
function getMinHealthFactor() external pure returns (uint256) {
return _MIN_HEALTH_FACTOR;
}
function getCollateralTokens() external view returns (address[] memory) {
return _collateralTokens;
}
function getDsc() external view returns (address) {
return address(i_dsc);
}
function getCollateralTokenPriceFeed(address token) external view returns (address) {
return priceFeeds[token];
}
function getHealthFactor(address user) external view returns (uint256) {
return _healthFactor(user);
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!