比特币源码研读---交易

2018-08-31  本文已影响0人  Hefe

0x00 废话

距离上次开篇已有半个多月了,平时晚上回家又懒,周末回家还要带娃,研读代码工作进展很慢,趁今天出差的路上又对代码梳理了一下,趁热赶出这篇文章。热情还在,只是对自己要求在降级,从产出高质量的文章,降级到但求没有错误(最怕误人子弟啊)。调试环境已从linux上gdb降级到windows上的 visual gdb;为了能一睹作者最初的设计思想,版本号也从0.14降级到0.8,隔离见证以及闪电网络的研读部分暂时不在范围之内。废话不多说,今天我们研读一下比特币的核心部分---交易。

0x01 UTXO

在比特币交易中有一个非常重要的概念UTXO(Unspent Transaction Output),也就是说比特币用UTXO取代了传统的账号系统。这句话如何理解呢,我们做个对比就知道了。假设A,B2位矿工分别挖到区块,获得coinbase奖励25btc,然后均转给C,C又转40个BTC给D。那么传统的账号系统,如下图:


传统账号

UTXO的流通方式如下:


UTXO

做过数据运维的朋友可能会说,UTXO不就是数据库里的日志表嘛?此话不假。我们知道银行或是其他的类似系统,不仅要对数据结果做落地入库,还要对交易记录做严格的管理,一旦发现数据异常,就要通过交易记录逐笔分析。所以严格意义上的讲,数据结果表是冗余的,因为结果是可以通过过程演算出来的,而且记录更容易查到那些作弊的人。当然记录推算结果也是要付出代价的,比如要消耗大量的计算,同时对记录的完整性有非常高的要求,不能有任何一条记录出错,否则全盘出错。比特币经过去中心化的分布式存储,以及共识机制的改良,把UTXO的思想发挥到了极致。

我们再来看一下master bitcoin的书中对UTXO的描述:

比特币交易的基本单位是未经使用的一个交易输出,简称UTXO。UTXO是不能再分割、被所有者锁住或记录于区块链中的并被整个网络识别成货币单位的一定量的比特币货币。比特币网络监测着以百万为单位的所有可用的(未花费的)UTXO。当一个用户接收比特币时,金额被当作UTXO记录到区块链里。这样,一个用户的比特币会被当作UTXO分散到数百个交易和数百个区块中。实际上,并不存在储存比特币地址或账户余额的地点,只有被所有者锁住的、分散的UTXO。“一个用户的比特币余额”,这个概念是一个通过比特币钱包应用创建的派生之物。比特币钱包通过扫描区块链并聚合所有属于该用户的UTXO来计算该用户的余额。

0x02 Transaction

下面我们来分析UTXO中的TX,TX就是transaction的缩写。CTransaction有两个重要的成员变量std::vector<CTxIn> vin和std::vector<CTxOut> vout,交易输入和交易输出。看下面的类关系图更会一目了然。


类关系图

(上图有个笔误,CTxOut中的scriptPubKey应该是锁定脚本)
对应的数据库序列化如下:

普通交易输入(CTxIn)

字节 字段 描述
32 交易哈希值 指向被花费的UTXO所在的交易的哈希指针
4 输出索引 被花费的UTXO的索引号,第一个是0
1-9 解锁脚本大小 用字节表示的后面的解锁脚本长度
不定 解锁脚本 满足UTXO解锁脚本条件的脚本
4 序列号 目前未被使用的交易替换功能,设为0xFFFFFFFF

普通交易输出(CTxOut)

字节 字段 描述
8 总量 用聪表示的比特币值
1-9 锁定脚本大小 用字节表示的后面的锁定脚本长度
不定 锁定脚本 一个定义了支付输出所需条件的脚本

交易(CTransaction)

字节 字段 描述
4 版本 明确这笔交易参照的规则
1-9 输入计数器 包含的交易输入数量
不定 输入 一个或多个交易输入
1-9 输出计数器 包含的交易输出数量
不定 输出 一个或多个交易输出
4 锁定时间 一个区块号或UNIX时间戳

创世coinbase

字节 字段 描述
4 版本 这笔交易参照的规则
1-9 输入计数器 包含的交易输入数量
32 交易哈希 不引用任何一个交易,值全部为0
4 交易输出索引 固定为0xFFFFFFFF
1-9 Coinbase数据长度 coinbase数据长度
不定 Coinbase数据 在V2版本的区块中,除了需要以区块高度开始外,其它数据可以任意填写,用于extra nonce和挖矿标签
4 顺序号 值全部为1,0xFFFFFFFF
1-9 输出计数器 包含的交易输出数量
8 总量 用聪表示的比特币值
1-9 锁定脚本大小 用字节表示的后面的锁定脚本长度
不定 锁定脚本 一个定义了支付输出所需条件的脚本
4 锁定时间 一个区块号或UNIX时间戳

CTxIn类代码

/** An input of a transaction.  It contains the location of the previous
 * transaction's output that it claims and a signature that matches the
 * output's public key.
 */
class CTxIn
{
public:
    COutPoint prevout;          //上一笔交易输出位置(通过hash定位到交易,通过索引定位到vout)
    CScript scriptSig;          //解锁脚本
    unsigned int nSequence;     //序列号

    CTxIn()
    {
        nSequence = std::numeric_limits<unsigned int>::max();
    }

    explicit CTxIn(COutPoint prevoutIn, CScript scriptSigIn=CScript(), unsigned int nSequenceIn=std::numeric_limits<unsigned int>::max())
    {
        prevout = prevoutIn;
        scriptSig = scriptSigIn;
        nSequence = nSequenceIn;
    }

    CTxIn(uint256 hashPrevTx, unsigned int nOut, CScript scriptSigIn=CScript(), unsigned int nSequenceIn=std::numeric_limits<unsigned int>::max())
    {
        prevout = COutPoint(hashPrevTx, nOut);
        scriptSig = scriptSigIn;
        nSequence = nSequenceIn;
    }

    IMPLEMENT_SERIALIZE
    (
        READWRITE(prevout);
        READWRITE(scriptSig);
        READWRITE(nSequence);
    )

    bool IsFinal() const
    {
        return (nSequence == std::numeric_limits<unsigned int>::max());
    }

    friend bool operator==(const CTxIn& a, const CTxIn& b)
    {
        return (a.prevout   == b.prevout &&
                a.scriptSig == b.scriptSig &&
                a.nSequence == b.nSequence);
    }

    friend bool operator!=(const CTxIn& a, const CTxIn& b)
    {
        return !(a == b);
    }

    std::string ToString() const
    {
        std::string str;
        str += "CTxIn(";
        str += prevout.ToString();
        if (prevout.IsNull())
            str += strprintf(", coinbase %s", HexStr(scriptSig).c_str());
        else
            str += strprintf(", scriptSig=%s", scriptSig.ToString().substr(0,24).c_str());
        if (nSequence != std::numeric_limits<unsigned int>::max())
            str += strprintf(", nSequence=%u", nSequence);
        str += ")";
        return str;
    }

    void print() const
    {
        printf("%s\n", ToString().c_str());
    }
};

CTxOut类代码

/** An output of a transaction.  It contains the public key that the next input
 * must be able to sign with to claim it.
 */
class CTxOut
{
public:
    int64 nValue;           //输出金额
    CScript scriptPubKey;   //锁定脚本

    CTxOut()
    {
        SetNull();
    }

    CTxOut(int64 nValueIn, CScript scriptPubKeyIn)
    {
        nValue = nValueIn;
        scriptPubKey = scriptPubKeyIn;
    }

    IMPLEMENT_SERIALIZE
    (
        READWRITE(nValue);
        READWRITE(scriptPubKey);
    )

    void SetNull()
    {
        nValue = -1;
        scriptPubKey.clear();
    }

    bool IsNull() const
    {
        return (nValue == -1);
    }

    uint256 GetHash() const
    {
        return SerializeHash(*this);
    }

    friend bool operator==(const CTxOut& a, const CTxOut& b)
    {
        return (a.nValue       == b.nValue &&
                a.scriptPubKey == b.scriptPubKey);
    }

    friend bool operator!=(const CTxOut& a, const CTxOut& b)
    {
        return !(a == b);
    }

    bool IsDust() const;

    std::string ToString() const
    {
        if (scriptPubKey.size() < 6)
            return "CTxOut(error)";
        return strprintf("CTxOut(nValue=%"PRI64d".%08"PRI64d", scriptPubKey=%s)", nValue / COIN, nValue % COIN, scriptPubKey.ToString().substr(0,30).c_str());
    }

    void print() const
    {
        printf("%s\n", ToString().c_str());
    }
};

CTransaction类代码

/** The basic transaction that is broadcasted on the network and contained in
 * blocks. A transaction can contain multiple inputs and outputs.
 */
 /**
 * 交易可以在公网中通过p2p进行广播,也可以被打包在区块中。
 * 每一笔交易可以有多个输入和输出。
 */
class CTransaction
{
public:
    static int64 nMinTxFee;             //最小交易手续费
    static int64 nMinRelayTxFee;        //最小传播交易手续费
    static const int CURRENT_VERSION=1; //当前版本号
    int nVersion;                       //版本号
    std::vector<CTxIn> vin;             //交易输入列表
    std::vector<CTxOut> vout;           //交易输出列表 
    unsigned int nLockTime;             //锁定时间

    CTransaction()
    {
        SetNull();
    }

    IMPLEMENT_SERIALIZE
    (
        READWRITE(this->nVersion);
        nVersion = this->nVersion;
        READWRITE(vin);
        READWRITE(vout);
        READWRITE(nLockTime);
    )

    void SetNull()
    {
        nVersion = CTransaction::CURRENT_VERSION;
        vin.clear();
        vout.clear();
        nLockTime = 0;
    }

    bool IsNull() const
    {
        return (vin.empty() && vout.empty());
    }

    uint256 GetHash() const
    {
        return SerializeHash(*this);
    }

    bool IsFinal(int nBlockHeight=0, int64 nBlockTime=0) const
    {
        // Time based nLockTime implemented in 0.1.6
        if (nLockTime == 0)
            return true;
        if (nBlockHeight == 0)
            nBlockHeight = nBestHeight;
        if (nBlockTime == 0)
            nBlockTime = GetAdjustedTime();
        if ((int64)nLockTime < ((int64)nLockTime < LOCKTIME_THRESHOLD ? (int64)nBlockHeight : nBlockTime))
            return true;
        BOOST_FOREACH(const CTxIn& txin, vin)
            if (!txin.IsFinal())
                return false;
        return true;
    }

    bool IsNewerThan(const CTransaction& old) const
    {
        if (vin.size() != old.vin.size())
            return false;
        for (unsigned int i = 0; i < vin.size(); i++)
            if (vin[i].prevout != old.vin[i].prevout)
                return false;

        bool fNewer = false;
        unsigned int nLowest = std::numeric_limits<unsigned int>::max();
        for (unsigned int i = 0; i < vin.size(); i++)
        {
            if (vin[i].nSequence != old.vin[i].nSequence)
            {
                if (vin[i].nSequence <= nLowest)
                {
                    fNewer = false;
                    nLowest = vin[i].nSequence;
                }
                if (old.vin[i].nSequence < nLowest)
                {
                    fNewer = true;
                    nLowest = old.vin[i].nSequence;
                }
            }
        }
        return fNewer;
    }

    bool IsCoinBase() const
    {
        return (vin.size() == 1 && vin[0].prevout.IsNull());
    }

    /** Check for standard transaction types
        @return True if all outputs (scriptPubKeys) use only standard transaction forms
    */
    /*
    * 判断该交易是否合法,主要通过检查交易的极端的size,
    * txin的scriptSig,以及txout的scriptPubKey
    */
    bool IsStandard() const;

    /** Check for standard transaction types
        @param[in] mapInputs    Map of previous transactions that have outputs we're spending
        @return True if all inputs (scriptSigs) use only standard transaction forms
    */
     /*
     *检查交易输入的scriptSigs的合法性
     */
    bool AreInputsStandard(CCoinsViewCache& mapInputs) const;

    /** Count ECDSA signature operations the old-fashioned (pre-0.6) way
        @return number of sigops this transaction's outputs will produce when spent
    */
    unsigned int GetLegacySigOpCount() const;

    /** Count ECDSA signature operations in pay-to-script-hash inputs.

        @param[in] mapInputs    Map of previous transactions that have outputs we're spending
        @return maximum number of sigops required to validate this transaction's inputs
     */
    unsigned int GetP2SHSigOpCount(CCoinsViewCache& mapInputs) const;

    /** Amount of bitcoins spent by this transaction.
        @return sum of all outputs (note: does not include fees)
     */
    int64 GetValueOut() const
    {
        int64 nValueOut = 0;
        BOOST_FOREACH(const CTxOut& txout, vout)
        {
            nValueOut += txout.nValue;
            if (!MoneyRange(txout.nValue) || !MoneyRange(nValueOut))
                throw std::runtime_error("CTransaction::GetValueOut() : value out of range");
        }
        return nValueOut;
    }

    /** Amount of bitcoins coming in to this transaction
        Note that lightweight clients may not know anything besides the hash of previous transactions,
        so may not be able to calculate this.

        @param[in] mapInputs    Map of previous transactions that have outputs we're spending
        @return Sum of value of all inputs (scriptSigs)
     */
    int64 GetValueIn(CCoinsViewCache& mapInputs) const;

    static bool AllowFree(double dPriority)
    {
        // Large (in bytes) low-priority (new, small-coin) transactions
        // need a fee.
        return dPriority > COIN * 144 / 250;
    }

    int64 GetMinFee(unsigned int nBlockSize=1, bool fAllowFree=true, enum GetMinFee_mode mode=GMF_BLOCK) const;

    friend bool operator==(const CTransaction& a, const CTransaction& b)
    {
        return (a.nVersion  == b.nVersion &&
                a.vin       == b.vin &&
                a.vout      == b.vout &&
                a.nLockTime == b.nLockTime);
    }

    friend bool operator!=(const CTransaction& a, const CTransaction& b)
    {
        return !(a == b);
    }


    std::string ToString() const
    {
        std::string str;
        str += strprintf("CTransaction(hash=%s, ver=%d, vin.size=%"PRIszu", vout.size=%"PRIszu", nLockTime=%u)\n",
            GetHash().ToString().c_str(),
            nVersion,
            vin.size(),
            vout.size(),
            nLockTime);
        for (unsigned int i = 0; i < vin.size(); i++)
            str += "    " + vin[i].ToString() + "\n";
        for (unsigned int i = 0; i < vout.size(); i++)
            str += "    " + vout[i].ToString() + "\n";
        return str;
    }

    void print() const
    {
        printf("%s", ToString().c_str());
    }


    // Check whether all prevouts of this transaction are present in the UTXO set represented by view
    // 判断该交易的所有的prevouts是否出现在视图的UTXO集合中
    bool HaveInputs(CCoinsViewCache &view) const;

    // Check whether all inputs of this transaction are valid (no double spends, scripts & sigs, amounts)
    // This does not modify the UTXO set. If pvChecks is not NULL, script checks are pushed onto it
    // instead of being performed inline.
    // 判断该交易的所有输入是否是合法(是否双花,scripts & sigs, 金额)
    bool CheckInputs(CValidationState &state, CCoinsViewCache &view, bool fScriptChecks = true,
                     unsigned int flags = SCRIPT_VERIFY_P2SH | SCRIPT_VERIFY_STRICTENC,
                     std::vector<CScriptCheck> *pvChecks = NULL) const;

    // Apply the effects of this transaction on the UTXO set represented by view
    // 更新该交易到视图的UTXO集合
    void UpdateCoins(CValidationState &state, CCoinsViewCache &view, CTxUndo &txundo, int nHeight, const uint256 &txhash) const;

    // Context-independent validity checks
    // 上下文无关的日常校验
    bool CheckTransaction(CValidationState &state) const;

    // Try to accept this transaction into the memory pool
    // 接受该交易,添加到交易池
    bool AcceptToMemoryPool(CValidationState &state, bool fCheckInputs=true, bool fLimitFree = true, bool* pfMissingInputs=NULL);

protected:
    static const CTxOut &GetOutputFor(const CTxIn& input, CCoinsViewCache& mapInputs);
};

关于锁定时间的解释如下:

交易的锁定时间
锁定时间定义了能被加到区块链里的最早的交易时间。在大多数交易里,它被设置成0,用来表示立即执行。如
果锁定时间不是0并且小于5亿,就被视为区块高度,意指在这个指定的区块高度之前,该交易没有被包含在区块
链里。如果锁定时间大于5亿,则它被当作是一个Unix纪元时间戳(从1970年1月1日以来的秒数),并且在这个
指定时点之前,该交易没有被包含在区块链里。锁定时间的使用相当于将一张纸质支票的生效时间予以后延。

0x03 交易流程

在熟悉交易的数据结构后,我们再来看一下交易流程:


交易流程

具体的步骤如下:
一、构造交易
钱包类CWallet的CreateTransaction函数创建交易。
主要分3步:
1、填充交易的输出交易(vout)
创建交易时,指定输出交易的信息,主要是输出的脚本(地址构造成CScript)、输出的币数量(nValue)。

 // vouts to the payees
 BOOST_FOREACH (const PAIRTYPE(CScript, int64)& s, vecSend)
 {
                    CTxOut txout(s.second, s.first);
                    if (txout.IsDust())
                    {
                        strFailReason = _("Transaction amount too small");
                        return false;
                    }
                    wtxNew.vout.push_back(txout);
   }

2、填充交易的输出交易(vin)
先从钱包的交易信息中选择合适的比特币(SelectCoins函数),填充到交易的输入交易中。
3、签名(CTxIn.scriptSig)
对输入交易的scriptSig签名(SignSignature函数)。
由新的交易信息、私钥计算哈希值(SignatureHash函数),填充到输入交易的scriptSig中(Solver函数)。构造交易完毕,再提交交易,发送出去。

 // Choose coins to use
 set<pair<const CWalletTx*,unsigned int> > setCoins;
 int64 nValueIn = 0;
 if (!SelectCoins(nTotalValue, setCoins, nValueIn))
  {                   
                    strFailReason = _("Insufficient funds");
                    return false;
  }

BOOST_FOREACH(PAIRTYPE(const CWalletTx*, unsigned int) pcoin, setCoins)
{
                    int64 nCredit = pcoin.first->vout[pcoin.second].nValue;
                    //The priority after the next block (depth+1) is used instead of the current,
                    //reflecting an assumption the user would accept a bit more delay for
                    //a chance at a free transaction.
                    dPriority += (double)nCredit * (pcoin.first->GetDepthInMainChain()+1);
 }
// Fill vin
BOOST_FOREACH(const PAIRTYPE(const CWalletTx*,unsigned int)& coin, setCoins)
wtxNew.vin.push_back(CTxIn(coin.first->GetHash(),coin.second));

// Sign
int nIn = 0;
BOOST_FOREACH(const PAIRTYPE(const CWalletTx*,unsigned int)& coin, setCoins)
if (!SignSignature(*this, *coin.first, wtxNew, nIn++))
{
         strFailReason = _("Signing transaction failed");
         return false;
 }

二、发送交易
当构造完交易,则提交交易(钱包类CWallet的CommitTransaction函数),发送出去。
提交交易时,先把交易添加到钱包中,然后标记旧的比特币为已经花费,再添加到交易内存池中,最后把交易传播下去。

// Add tx to wallet, because if it has change it's also ours,
// otherwise just for transaction history.
AddToWallet(wtxNew);

// Mark old coins as spent
set<CWalletTx*> setCoins;
BOOST_FOREACH(const CTxIn& txin, wtxNew.vin)
{
                CWalletTx &coin = mapWallet[txin.prevout.hash];
                coin.BindWallet(this);
                coin.MarkSpent(txin.prevout.n);
                coin.WriteToDisk();
                NotifyTransactionChanged(this, coin.GetHash(), CT_UPDATED);
}
// Broadcast
if (!wtxNew.AcceptToMemoryPool(true, false))
{
            // This must not fail. The transaction has already been signed and recorded.
            printf("CommitTransaction() : Error: Transaction not valid");
            return false;
}
wtxNew.RelayWalletTransaction();

传播交易时(RelayTransaction函数),由交易信息的哈希值构造CInv,类型
为MSG_TX,添加到每个节点的发送清单(vInventoryToSend)中,发送消息
(SendMessages)时把节点中的发送清单中的交易信息以”inv”命令发送出去。

void CWalletTx::RelayWalletTransaction()
{
    BOOST_FOREACH(const CMerkleTx& tx, vtxPrev)
    {
        if (!tx.IsCoinBase())
            if (tx.GetDepthInMainChain() == 0)
                RelayTransaction((CTransaction)tx, tx.GetHash());
    }
    if (!IsCoinBase())
    {
        if (GetDepthInMainChain() == 0) {
            uint256 hash = GetHash();
            printf("Relaying wtx %s\n", hash.ToString().c_str());
            RelayTransaction((CTransaction)*this, hash);
        }
    }
}
void RelayTransaction(const CTransaction& tx, const uint256& hash, const CDataStream& ss)
{
    CInv inv(MSG_TX, hash);
    {
        LOCK(cs_mapRelay);
        // Expire old relay messages
        while (!vRelayExpiration.empty() && vRelayExpiration.front().first < GetTime())
        {
            mapRelay.erase(vRelayExpiration.front().second);
            vRelayExpiration.pop_front();
        }

        // Save original serialized message so newer versions are preserved
        mapRelay.insert(std::make_pair(inv, ss));
        vRelayExpiration.push_back(std::make_pair(GetTime() + 15 * 60, inv));
    }
    LOCK(cs_vNodes);
    BOOST_FOREACH(CNode* pnode, vNodes)
    {
        if(!pnode->fRelayTxes)
            continue;
        LOCK(pnode->cs_filter);
        if (pnode->pfilter)
        {
            if (pnode->pfilter->IsRelevantAndUpdate(tx, hash))
                pnode->PushInventory(inv);
        } else
            pnode->PushInventory(inv);
    }
}

三、接收交易
当节点接收到交易命令(”tx”)后,把交易信息添加到交易内存池中,且传播
下去,详见ProcessMessage函数。

else if (strCommand == "tx")
    {
        vector<uint256> vWorkQueue;
        vector<uint256> vEraseQueue;
        CDataStream vMsg(vRecv);
        CTransaction tx;
        vRecv >> tx;

        CInv inv(MSG_TX, tx.GetHash());
        pfrom->AddInventoryKnown(inv);

        bool fMissingInputs = false;
        CValidationState state;
        if (tx.AcceptToMemoryPool(state, true, true, &fMissingInputs))
        {
            RelayTransaction(tx, inv.hash, vMsg);
            mapAlreadyAskedFor.erase(inv);
            vWorkQueue.push_back(inv.hash);
            vEraseQueue.push_back(inv.hash);
            // Recursively process any orphan transactions that depended on this one
            for (unsigned int i = 0; i < vWorkQueue.size(); i++)
            {
                uint256 hashPrev = vWorkQueue[i];
                for (map<uint256, CDataStream*>::iterator mi = mapOrphanTransactionsByPrev[hashPrev].begin();
                     mi != mapOrphanTransactionsByPrev[hashPrev].end();
                     ++mi)
                {
                    const CDataStream& vMsg = *((*mi).second);
                    CTransaction tx;
                    CDataStream(vMsg) >> tx;
                    CInv inv(MSG_TX, tx.GetHash());
                    bool fMissingInputs2 = false;
                    // Use a dummy CValidationState so someone can't setup nodes to counter-DoS based on orphan resolution (that is, feeding people an invalid transaction based on LegitTxX in order to get anyone relaying LegitTxX banned)
                    CValidationState stateDummy;

                    if (tx.AcceptToMemoryPool(stateDummy, true, true, &fMissingInputs2))
                    {
                        printf("   accepted orphan tx %s\n", inv.hash.ToString().c_str());
                        RelayTransaction(tx, inv.hash, vMsg);
                        mapAlreadyAskedFor.erase(inv);
                        vWorkQueue.push_back(inv.hash);
                        vEraseQueue.push_back(inv.hash);
                    }
                    else if (!fMissingInputs2)
                    {
                        // invalid or too-little-fee orphan
                        vEraseQueue.push_back(inv.hash);
                        printf("   removed orphan tx %s\n", inv.hash.ToString().c_str());
                    }
                }
            }

            BOOST_FOREACH(uint256 hash, vEraseQueue)
                EraseOrphanTx(hash);
        }
        else if (fMissingInputs)
        {
            AddOrphanTx(vMsg);

            // DoS prevention: do not allow mapOrphanTransactions to grow unbounded
            unsigned int nEvicted = LimitOrphanTxSize(MAX_ORPHAN_TRANSACTIONS);
            if (nEvicted > 0)
                printf("mapOrphan overflow, removed %u tx\n", nEvicted);
        }
        int nDoS;
        if (state.IsInvalid(nDoS))
            pfrom->Misbehaving(nDoS);
    }

由于本人水平有限,文中难免有理解不透的地方,还请大家多多指教!

本文由区块链研习社源码研读班@Hefe原创,转载请注明出处!

上一篇下一篇

猜你喜欢

热点阅读