比特币全节点处理交易流程分析

我们知道,比特币网络上的交易需要由节点验证并传播,那么当节点收到一笔交易时,到底做了哪些处理?具体的流程又是怎样?使用了什么样的数据结构?网络上的文章大多是泛泛而谈,读者看的云里雾里,难以理出清晰的脉络。本文旨在解决读者的疑惑,通过剖析比特币的源码,勾勒出节点处理交易时的完整流程图。

注1:本文的分析以中本聪的代码v0.1.15版本为蓝本,节点指代为全节点。
注2:如果对比特币交易的一些基本概念还不清楚,请先阅读MasteringBitcoin这本书的相应章节,此书中文版叫做《精通比特币》。
注3:文中提到的关键步骤会贴出相应源码,非关键部分请参考流程图自行去看源码
注4:文中提到的交易处理流程针对单笔交易(loose transaction)

1. 约定术语

为避免混淆,首先对一些容易产生歧义的概念作出约定,先来看一笔交易的结构。比特币的交易结构采用UTXO模型,交易主要由输入输出两个数组构成。

{
  "version": 1,
  "locktime": 0,
  "vin": [
    {
      "txid":"7957a35fe64f80d234d76d83a2a8f1a0d8149a41d81de548f0a65a8a999f6f18",
      "vout": 0,
      "scriptSig": "3045022100884d142d86652a3f47ba4746ec719bbfbd040a570b1deccbb6498c75c4ae24cb02204b9f039ff08df09cbe9f6addac960298cad530a863ea8f53982c09db8f6e3813[ALL] 0484ecc0d46f1918b30928fa0e4ed99f16a0fb4fde0735e7ade8416ab9fe423cc5412336376789d172787ec3457eee41c04f4938de5cc17b4a10fa336a8d752adf",
      "sequence": 4294967295
    }
 ],
  "vout": [
    {
      "value": 0.01500000,
      "scriptPubKey": "OP_DUP OP_HASH160 ab68025513c3dbd2f7b92a94e0581f5d50f654e7 OP_EQUALVERIFY OP_CHECKSIG"
    },
    {
      "value": 0.08450000,
      "scriptPubKey": "OP_DUP OP_HASH160 7f9b1a7fb68d60c536c2fd8aeaa53a8f3cc025a8 OP_EQUALVERIFY OP_CHECKSIG",
    }
  ]
}

引用:我们说此笔交易引用了txid为7957....6f18的交易。

子交易: 此笔交易作为引用者,可以称作子交易。

父交易: txid为7957....6f18的交易作为被引用者,可以称作父交易。

孤儿交易: 找不到父交易的子交易,比如在交易池或本地区块链中找不到txid为7957....6f18的交易,我们就称子交易为孤儿交易。

交易池:内存的一块区域,存放已经通过验证待被打包进入区块链的交易。

孤儿交易池: 内存的一块区域,存放通过验证但找不到父交易的孤儿交易。

注意:交易的结构是多输入多输出的,所以父交易对应多个子交易,子交易也对应多个父交易,这与通常的父子概念不同,只是代表了引用和被引用的关系

2. 关键的数据结构

2.1 关键类

首先是交易类,nVersion和nLockTime涉及到脚本的知识,会在后面的文章提及。交易类的主要内容是输入输出数组,输入是CTxIn对象的集合,输出是CTxOut对象的集合。

class CTransaction
{
public:
    int nVersion;
    vector<CTxIn> vin;   //输入数组
    vector<CTxOut> vout; //输出数组
    int nLockTime;
    /*成员函数略*/
}

CTxIn类是输入的结构,由CoutPoint、解锁脚本和nSequence构成。nSequence字段默认填最大值,在替换交易时有用,这里不做过多的解释。

class CTxIn
{
public:
    COutPoint prevout; //被引用交易的hash和vout索引
    CScript scriptSig; //解锁脚本
    unsigned int nSequence;
    /*成员函数略*/
}

CoutPoint类保存父交易的hash值和vout数组的索引,我们看到交易输入的重要部分其实就是父交易的输出。

class COutPoint
{
public:
    uint256 hash;   //交易hash,即txid
    unsigned int n; //输出索引
    /*成员函数略*/
}

CTxOut类是输出的结构,由交易额和锁定脚本构成。

class CTxOut
{
public:
    int64 nValue;         //交易额
    CScript scriptPubKey; //锁定脚本
    /*成员函数略*/
}

CInPoint类是相对CoutPoint出现的一个类,存的是子交易的指针和输入数组的索引

class CInPoint
{
public:
    CTransaction* ptx; //子交易指针
    unsigned int n;    //数组vin索引
    /*成员函数略*/
}

CTxIndex类的作用是保存交易在硬盘中的位置,他还可以记录交易的输出中哪些是已经被花掉的,这在双花检查中非常重要。

class CTxIndex
{
public:
    CDiskTxPos pos;            //交易在硬盘中的位置
    vector<CDiskTxPos> vSpent; //此笔交易的输出被花费标志位
}

2.2 关键全局变量

❖交易池

交易池的数据结构由两个全局map构成。mapTransaction的key是交易的hash值,value是交易本身,这个map是交易池的主要结构。mapNextTx以COutPoint为key,以CinPoint为value,存的是父子交易的联系,在检测内存冲突时有用,是交易池的辅助结构。

map<uint256, CTransaction> mapTransactions; //key: 交易hash value:交易
map<COutPoint, CInPoint>   mapNextTx; //key: 父交易的hash和vout索引 value:子交易的指针和vin索引
❖孤儿交易池

孤儿交易池的数据结构也由两个全局map构成,但和交易池略有不同。首先value存的是交易指针,交易在堆中保存,另外mapOrphanTransactionsByPrev是一个multimap,因为一笔交易有多个输出,可对应多笔孤儿交易。

map<uint256, CDataStream*> mapOrphanTransactions; //key: 孤儿交易的hash value:孤儿交易的指针
multimap<uint256, CDataStream*> mapOrphanTransactionsByPrev; //key: 父交易的hash value:孤儿交易的指针

3. 总体流程

下面介绍节点收到单笔交易(loose transaction)并进行处理的整体流程,先贴出流程图


接下来对流程图中的重要部分做出说明

❖交易验证

处理一笔交易时首先要对交易进行验证,判断能否被放入交易池。验证交易的函数是AcceptTransaction。验证交易的流程会在后面单独展开,这里先提一下。

bool AcceptTransaction(CTxDB& txdb, bool fCheckInputs=true, bool* pfMissingInputs=NULL);

bool AcceptTransaction(bool fCheckInputs=true, bool* pfMissingInputs=NULL)
{
    CTxDB txdb("r");
    //调用重载的另一个AcceptTransaction,第一个参数是本地数据库blkindex.dat
    return AcceptTransaction(txdb, fCheckInputs, pfMissingInputs);
}
❖加入孤儿交易池

我们看到,AcceptTransaction函数有一个传出参数是pfMissingInput, 如果交易未能通过验证,且原因是在区块链和交易池中找不到这笔交易的所有父交易,那么传出参数将会被置True,交易会被放入孤儿交易池。这里贴出交易加入孤儿池的代码

void AddOrphanTx(const CDataStream& vMsg)
{
    CTransaction tx;
    CDataStream(vMsg) >> tx;
    uint256 hash = tx.GetHash();
    if (mapOrphanTransactions.count(hash))
        return;
    CDataStream* pvMsg = mapOrphanTransactions[hash] = new CDataStream(vMsg);
    //孤儿交易的所有父交易,都要和孤儿交易组成键值对,存入multimap
    foreach(const CTxIn& txin, tx.vin)
        mapOrphanTransactionsByPrev.insert(make_pair(txin.prevout.hash, pvMsg));
}
❖加入钱包,广播交易

如果交易通过验证,判断交易的输出中是否有发给本节点的(看交易的锁定脚本的公钥hash),如果有就存入钱包,然后向其他节点转播交易,这涉及到钱包和p2p网络的知识,在本篇文章中不会分析这个子流程。

❖排查孤儿交易池

继续向下看,交易hash值被放入一个名叫vWorkQueue的vector中,这个vector保存通过验证的交易。我们需要遍历这个vector,处理此交易对应的孤儿交易集合。首先获得这个孤儿交易集合,然后遍历集合,对集合的每笔交易进行交易验证(这里验证函数的传出参数pfMissingInputs不生效,因为交易已在孤儿交易池),如果验证通过就加入vWorkQueue。这样形成了递归,凡不再是孤儿交易的交易都会被从孤儿交易池中捞出来,放在vWorkQueue中。我们从流程图下方中的两个循环中可以清楚地看到这个流程,这块的代码也贴出来

vWorkQueue.push_back(inv.hash);

// Recursively process any orphan transactions that depended on this one
//递归处理与此笔交易有关的孤儿交易
for (int i = 0; i < vWorkQueue.size(); i++)
{
    //此笔交易的hash,对于孤儿交易是父交易
    uint256 hashPrev = vWorkQueue[i];
    //遍历这笔交易对应的孤儿交易
    for (multimap<uint256, CDataStream*>::iterator mi = mapOrphanTransactionsByPrev.lower_bound(hashPrev);
         mi != mapOrphanTransactionsByPrev.upper_bound(hashPrev);
         ++mi)
    {
        const CDataStream& vMsg = *((*mi).second);
        CTransaction tx;
        CDataStream(vMsg) >> tx;
        CInv inv(MSG_TX, tx.GetHash());
        //孤儿交易找到一个父交易, 对孤儿交易重新调用AcceptTransaction函数,此时不用填fMissingInput参数,因为孤儿交易已经在孤儿池中
        if (tx.AcceptTransaction(true))
        {
            printf("   accepted orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str());
            AddToWalletIfMine(tx, NULL);
            RelayMessage(inv, vMsg);
            mapAlreadyAskedFor.erase(inv);
            //接受了的孤儿交易也放到vWorkQueque中,因此有了递归
            vWorkQueue.push_back(inv.hash);
        }
    }
}
❖ 整理孤儿交易池

最后一步好理解, 孤儿交易池中能捞出来的已经全被捞出来了,对照vWorkQueue整理孤儿交易池的两个map就好了,这里代码就不贴出了。

4. 验证交易步骤

在总体流程中为了保持脉络清晰没有展开说验证交易的步骤,实际上验证交易需要很多步骤,先看流程图


从图中可以看到,验证交易分六个大步骤:

❖coinbase检查

如果收到的交易是coinbase交易,那么验证失败,因为coinbase交易只能出现在区块中,在网络上传播的单笔交易一定是错误的。

❖常规检查

常规检查会查看输入输出是否为空,查看交易额是否为负值,输入来源是否为null。

❖交易是否已经存在

从交易池、区块链账本中查找此笔交易是否已经存在。对于交易池,直接查找mapTransactions就可以了。对于区块链账本,需要查找数据库,对应的数据库文件是blkindex.dat。

❖冲突检查

这是检查交易池的一个关键步骤,如果这笔交易与交易池中的某笔交易所引用的交易是一模一样的,那么可以认定这两笔交易是一样的,只是新旧不同(通过nSequence比较),会用新交易替代旧交易;如果这笔交易与交易池中的某笔交易所引用的交易只是部分相同,这种情况就属于冲突,后收到的交易一定会被节点拒绝。此处的代码初看时有点令人费解,网上有些源码分析的解释是错误的。贴出源码:

// Check for conflicts with in-memory transactions
//检查与内存中其他交易的冲突, 只有在内存里找到与当前这笔交易引用的输出是一模一样的交易,才比较新旧
CTransaction* ptxOld = NULL;
for (int i = 0; i < vin.size(); i++)
{
    //获得这笔交易的来源,就是父交易的hash和out索引
    COutPoint outpoint = vin[i].prevout;
    if (mapNextTx.count(outpoint))
    {
        // Allow replacing with a newer version of the same transaction
        //必须是所有的outpoint都满足,非0,代表第一个没满足条件,就可以直接return false了
        if (i != 0)
            return false;
        ptxOld = mapNextTx[outpoint].ptx;
        //当前交易如果不比旧的新,return false,谁有最大的nSequence谁更新
        if (!IsNewerThan(*ptxOld))
            return false;
        //确保ptxOld和新交易是同一笔交易
        for (int i = 0; i < vin.size(); i++)
        {
            COutPoint outpoint = vin[i].prevout;
            if (!mapNextTx.count(outpoint) || mapNextTx[outpoint].ptx != ptxOld)
                return false;
        }
        break;
    }
}
❖输入检查

冲突检查检查的是与交易池中交易的冲突,输入检查检查的则是与区块链中交易的冲突,这个流程比较复杂,会在后面单独展开。

❖加入交易池

如果上面5步的检查都通过了,剩下的工作就是把交易放入交易池了。如果上面的冲突检查中ptxOld不为null, 还要把旧的交易从交易池中移除。很多人觉得交易池很神秘,其实一点也不神秘。贴出将交易添加到交易池的代码,只有几行

bool CTransaction::AddToMemoryPool()
{
    // Add to memory pool without checking anything.  Don't call this directly,
    // call AcceptTransaction to properly check the transaction first.
    CRITICAL_BLOCK(cs_mapTransactions)
    {
        uint256 hash = GetHash();
        mapTransactions[hash] = *this;
        for (int i = 0; i < vin.size(); i++)
            mapNextTx[vin[i].prevout] = CInPoint(&mapTransactions[hash], i);
        nTransactionsUpdated++;
    }
    return true;
}

5. 输入检查

下面展开分析输入检查的流程,这是检查流程中的重要部分,包含了验证签名和双花检查两个核心步骤,先贴出流程图


下面针对重要的部分展开分析

❖查找父交易位置

我们从图中看到,首先遍历交易的输入序列,然后在本地区块链中查找父交易的位置,如果找不到就在内存中找,在内存中还找不到的话,就会将*pfMissInputs置true, 可以看出只要有一个父交易找不到子交易就被认为是孤儿交易。针对找到的情况,用一个CTxIndex类型的局部变量txIndex保存父交易的位置。如果父交易在区块链中,则位置信息有数据,父交易在交易池中,位置信息无数据。

❖父交易coinbase进行检查

现在我们知道了父交易的位置信息,接下来用一个局部变量txPrev保存父交易,由于coinbase交易必须要等100个确认后矿工的比特币才能被花掉,因此如果父交易是coinbase交易的话,满足100个确认才能验证(比较区块链高度)通过.

❖验证签名

下面进行验证签名检查,验证签名涉及脚本和椭圆曲线加密算法的知识,其原理会在后续的文章展开讨论。这里讲下流程,将子交易的解锁脚本和父交易的锁定脚本连起来,然后调用脚本执行函数,执行过程类似于压栈弹栈操作。执行完脚本后,如果子交易的发起者拥有父交易锁定脚本中公钥hash对应的私钥,那么解锁脚本会解开锁定脚本,函数会返回True, 贴出验证签名的代码

bool VerifySignature(const CTransaction& txFrom, const CTransaction& txTo, unsigned int nIn, int nHashType)
{
    assert(nIn < txTo.vin.size());
    const CTxIn& txin = txTo.vin[nIn];
    if (txin.prevout.n >= txFrom.vout.size())
        return false;
    const CTxOut& txout = txFrom.vout[txin.prevout.n];

    if (txin.prevout.hash != txFrom.GetHash())
        return false;

    return EvalScript(txin.scriptSig + CScript(OP_CODESEPARATOR) + txout.scriptPubKey, txTo, nIn, nHashType);
}
❖检查双花

我们既然拿到了父交易的位置信息,就可以查看这个结构体的vSpent部分,如果txIndex.vSpent[prevout.n]已经被置位了,说明父交易的这个输出已经被花掉了,子交易的输入用了已经被花费掉的输出,这就产生了“双花”(double spend)。因此,验证不会通过。

❖标志位置位

双花检查通过,接下来将txIndex.vSpent[prevout.n]置位,由于我们验证的是单笔交易(loose trsanction), 这笔交易并没有入块,所以这个标志位只要被置位即可,内容没有要求,位置也不会被保存。如果验证的是块中交易,这个标志位要填块的位置,并且这个位置会被写入数据库。

❖计算手续费

遍历完所有父交易后,我们可以累加得到输入的总金额,用这个金额减去输出总金额,就得到了当前交易的手续费,如果手续费大于最小手续费,可以验证通过。

此处贴出输入检查的源码

bool CTransaction::ConnectInputs(CTxDB& txdb, map<uint256, CTxIndex>& mapTestPool, CDiskTxPos posThisTx, int nHeight, int64& nFees, bool fBlock, bool fMiner, int64 nMinFee)
{
    // Take over previous transactions' spent pointers
    if (!IsCoinBase())
    {
        int64 nValueIn = 0;
        for (int i = 0; i < vin.size(); i++)
        {
            COutPoint prevout = vin[i].prevout;

            // Read txindex
            CTxIndex txindex;
            bool fFound = true;
            if (fMiner && mapTestPool.count(prevout.hash))
            {
                // Get txindex from current proposed changes
                txindex = mapTestPool[prevout.hash];
            }
            else
            {
                // Read txindex from txdb
                //查引用的交易在硬盘中的位置,把位置存到txindex中
                fFound = txdb.ReadTxIndex(prevout.hash, txindex);
            }
            if (!fFound && (fBlock || fMiner))
                return fMiner ? false : error("ConnectInputs() : %s prev tx %s index entry not found", GetHash().ToString().substr(0,6).c_str(),  prevout.hash.ToString().substr(0,6).c_str());

            // Read txPrev
            CTransaction txPrev;
            //在硬盘里没查到引用的这笔交易
            if (!fFound || txindex.pos == CDiskTxPos(1,1,1))
            {
                // Get prev tx from single transactions in memory
                CRITICAL_BLOCK(cs_mapTransactions)
                {
                    //在内存池(mapTransaction)里查引用的这笔交易
                    if (!mapTransactions.count(prevout.hash))
                        return error("ConnectInputs() : %s mapTransactions prev not found %s", GetHash().ToString().substr(0,6).c_str(),  prevout.hash.ToString().substr(0,6).c_str());
                    txPrev = mapTransactions[prevout.hash];
                }
                if (!fFound)
                    txindex.vSpent.resize(txPrev.vout.size());
            }
            else
            {
                // Get prev tx from disk
                if (!txPrev.ReadFromDisk(txindex.pos))
                    return error("ConnectInputs() : %s ReadFromDisk prev tx %s failed", GetHash().ToString().substr(0,6).c_str(),  prevout.hash.ToString().substr(0,6).c_str());
            }

            if (prevout.n >= txPrev.vout.size() || prevout.n >= txindex.vSpent.size())
                return error("ConnectInputs() : %s prevout.n out of range %d %d %d", GetHash().ToString().substr(0,6).c_str(), prevout.n, txPrev.vout.size(), txindex.vSpent.size());

            // If prev is coinbase, check that it's matured
            if (txPrev.IsCoinBase())
                for (CBlockIndex* pindex = pindexBest; pindex && nBestHeight - pindex->nHeight < COINBASE_MATURITY-1; pindex = pindex->pprev)
                    if (pindex->nBlockPos == txindex.pos.nBlockPos && pindex->nFile == txindex.pos.nFile)
                        return error("ConnectInputs() : tried to spend coinbase at depth %d", nBestHeight - pindex->nHeight);

            // Verify signature
            if (!VerifySignature(txPrev, *this, i))
                return error("ConnectInputs() : %s VerifySignature failed", GetHash().ToString().substr(0,6).c_str());

            // Check for conflicts
            if (!txindex.vSpent[prevout.n].IsNull())
                return fMiner ? false : error("ConnectInputs() : %s prev tx already used at %s", GetHash().ToString().substr(0,6).c_str(), txindex.vSpent[prevout.n].ToString().c_str());

            // Mark outpoints as spent
            txindex.vSpent[prevout.n] = posThisTx;

            // Write back
            if (fBlock)
                txdb.UpdateTxIndex(prevout.hash, txindex);
            else if (fMiner)
                mapTestPool[prevout.hash] = txindex;

            nValueIn += txPrev.vout[prevout.n].nValue;
        }

        // Tally transaction fees
        int64 nTxFee = nValueIn - GetValueOut();
        if (nTxFee < 0)
            return error("ConnectInputs() : %s nTxFee < 0", GetHash().ToString().substr(0,6).c_str());
        if (nTxFee < nMinFee)
            return false;
        nFees += nTxFee;
    }

    if (fBlock)
    {
        // Add transaction to disk index
        if (!txdb.AddTxIndex(*this, posThisTx, nHeight))
            return error("ConnectInputs() : AddTxPos failed");
    }
    else if (fMiner)
    {
        // Add transaction to test pool
        mapTestPool[GetHash()] = CTxIndex(CDiskTxPos(1,1,1), vout.size());
    }

    return true;
}

至此,全节点处理交易的流程就理清了,读者最好对照流程图去阅读一遍源码,这样会理解的更加透彻。

BTech原创,未经许可不得转载

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,670评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,928评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,926评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,238评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,112评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,138评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,545评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,232评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,496评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,596评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,369评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,226评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,600评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,906评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,185评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,516评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,721评论 2 335

推荐阅读更多精彩内容