二十.读写交易索引和验证交易

我们来接上一章的ConnectBlock函数,在其下调用了这个函数。

1.ConnectInputs

这里有一句关键的代码,每遍历一次,调用了这个:

cpp 复制代码
    if (!tx.ConnectInputs(txdb, mapUnused, posThisTx, pindex->nHeight, nFees, true, false))
        return false;

也就是调用了tx(CTransaction)下的ConnectInputs函数,这就是写交易索引了的具体地方了。

注意,写交易索引就代表了交易的合法性,所以这种函数也会有各种检测,包括验证签名等。

通过了才会写进去。

cpp 复制代码
bool CTransaction::ConnectInputs(CTxDB& txdb, map<uint256, CTxIndex>& mapTestPool, CDiskTxPos posThisTx, int nHeight, int64& nFees, bool fBlock, bool fMiner, int64 nMinFee)
{
    // 如果是 coinbase 交易(挖矿奖励),则不需要处理输入,直接跳过
    if (!IsCoinBase())
    {
        int64 nValueIn = 0;                     // 累计本交易所有输入的总金额

        // 遍历本交易的每一个输入(vin)
        for (int i = 0; i < vin.size(); i++)
        {
            COutPoint prevout = vin[i].prevout;   // 当前输入引用的前一个交易的输出点 (txid + 输出索引)

            // Read txindex
            CTxIndex txindex;                     // 用于存储前一个交易的索引信息(包含哪些输出已被花费)
            bool fFound = true;

            // 如果当前是在挖矿验证模式(fMiner=true),且前一个交易在本区块内已处理过
            // 则优先从临时测试池 mapTestPool 中获取最新状态(支持同一区块内交易互相花费)
            if (fMiner && mapTestPool.count(prevout.hash))
            {
                // 从当前正在验证的区块的临时池中获取 txindex
                txindex = mapTestPool[prevout.hash];
            }
            else
            {
                // 否则从磁盘数据库中读取已确认的交易索引
                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)
                {
                    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
            {
                // 从磁盘读取前一个交易的完整数据
                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());

            // 如果前一个交易是 coinbase,则检查是否已经成熟(Coinbase Maturity)
            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);

            // 验证当前输入的签名是否正确
            if (!VerifySignature(txPrev, *this, i))
                return error("ConnectInputs() : %s VerifySignature failed", GetHash().ToString().substr(0,6).c_str());

            // 检查这个输出是否已经被其他交易花费(双花检查)
            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
            // 根据不同模式,将更新后的 txindex 写回对应位置
            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;           // 累加到整个区块的手续费中
    }

    // 如果是正式连接区块(fBlock=true),则把本交易加入磁盘交易索引
    if (fBlock)
    {
        if (!txdb.AddTxIndex(*this, posThisTx, nHeight))
            return error("ConnectInputs() : AddTxPos failed");
    }
    // 如果是挖矿验证模式,则把本交易加入临时测试池(用于后续交易引用)
    else if (fMiner)
    {
        mapTestPool[GetHash()] = CTxIndex(CDiskTxPos(1,1,1), vout.size());
    }

    return true;
}

2.mapTestPool

这个ConnectInputs函数,首先干的就是获取这笔tx其下的所有vin,一个vin定位到一笔vout,它就会把这笔vout所属的tx(CTransaction)的CTxIndex获取到,根据哈希值。

但是这种获取有两种情况,看下列if逻辑:

cpp 复制代码
     if (fMiner && mapTestPool.count(prevout.hash))

注意啊,我们这里的mapTestPool是空的,既然是空的为什么还要加上这个判断,这并不是写错了,因为调用ConnectInputs不仅仅一个地方,其他函数也会调用ConnectInputs.(fmIner=true,挖矿时)那里的mapTestPool是有效的。

我们先来说说为什么会有这个,先要了解一个概念,即一笔vin它引用的可能是同一个区块的vout。这种情况是有可能发生的。

比如差不多同一时间,A有1btc,A转给B 0.8btc,是不是还要找零给自己0.2btc。

A又把这0.2BTC,转给了C。

此时的这下一笔vin引用的vout,是不是这个同一区块的0.2btc。

那么此时。这个区块还未写进数据库,你想构建引用的这笔CTxIndex索引,你在数据库读不到,你就只能到池子中去读取。所以里会有这样一个判断,如果不是一个区块的的话,你可以看它接下来的代码:

cpp 复制代码
         else
         {
             // Read txindex from txdb
             fFound = txdb.ReadTxIndex(prevout.hash, txindex);
         }

直接从数据库里读取。

11.CDiskTxPos(1,1,1)

读到引用的这笔交易索引,然后干什么呢?反序列化成CTransaction,那是不是要到磁盘里读这一笔tx的数据呢?但前面说了,由于可能在于同一个区块,同样的这笔tx还未写到磁盘里,所以会有下面的判断:

cpp 复制代码
       if (!fFound || txindex.pos == CDiskTxPos(1,1,1))

这个标志CDiskTxPos(1,1,1),这是特定的标志,表示同一个区块,因为如果你这笔交易未写进磁盘,那肯定也没有偏移位置,所以都用1,1,1代替,表示一种特殊的CDiskTxPos。

如果磁盘里有,直接就是这一句从磁盘里读:

cpp 复制代码
         if (!txPrev.ReadFromDisk(txindex.pos))

如果没有,则是这个从内存池中读:

cpp 复制代码
   txPrev = mapTransactions[prevout.hash];

但是这里还多了一句:

cpp 复制代码
             if (!fFound)
                 txindex.vSpent.resize(txPrev.vout.size());

首先我们来分析!fFound这种情况存不存在,也就是什么时候fFound为假,引用的这笔交易mapTestPool找不到,但是根据哈希值在数据库也读取不到,但是在mapTransactions是能找到的。

那么此时就走另一条分支。这条分支txindex的vSpent是未分配空间的,所以需要分配一下。

否则后面的赋值将会报错。

3.vSpent

到了这里,我们来了解一下CTxIndex下的vSpent是干什么的,为后续的代码做铺垫。

我们先来看定义:

cpp 复制代码
class CTxIndex
{
public:
    CDiskTxPos pos;
    vector<CDiskTxPos> vSpent;

这个vSpent记录了这笔tx已花费的vout,就是这个tx下有多少个vout,这个vSpent数组就会有多少个。每一个元素对应一笔vout,按顺序,第1个元素对应这笔tx第一个vout。

如果这个vout是已花费的,比如说第二个,那么对应的这个vSpent[2]就会被赋值指向这笔花费此vout的tx,通过CDiskTxPos定位。注意这里是指向tx,并不是具体的vin,需要知道具体的vin,是要先把整个tx读取出来,然后看其下的哪个vin引用了我这个vout,通过vin下的也就是CTxIn下的prevout.n,如果为2,则匹配成功。

好,了解了vSpent后我们来看接下来的代码。

这里的每笔vin都必须有对应的vout,如果我引用的是某笔tx下的第36个vout,但是我把这笔tx读取出来后,发现其下总共才只有10个vout。是不是出错了,这可能是一个伪造的交易。

然后大于这笔tx索引的vSpent也是同理,正常情况下vout.size和vSpent.size是相等的。

所以会有这个判断:

cpp 复制代码
  if (prevout.n >= txPrev.vout.size() || prevout.n >= txindex.vSpent.size())

4.IsCoinBase

接下来判断,你定位到这笔vout,是否为coinbase交易,也就是Block下的第一笔tx,为什么要判断一下,因为有着不同的处理逻辑。我们来看代码:

cpp 复制代码
            // 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);

因为如果你花费的是coinbase要等到100个确认区块后才能花费,这是为了防止特定的攻击。

关于普通花费为什么不需要。篇幅所限以后单独说一下吧。

这里只需要明白,这是计算是否在100个区块之后,根据这个参数:

cpp 复制代码
static const int COINBASE_MATURITY = 100;

不符合则拒绝此笔交易。

5.VerifySignature

然后就是大名顶顶的签名验证了:

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

具体逻辑也同样不说明,需要很长的篇幅描述,你只需要知道,现在只要调用VerifySignature函数就能完成它。些函数将在后续章节说明。

然后判断,你定位的这笔vout是否被花费了,如果已经花费过了,那是不是你在重复支付。你在花已经用掉的钱,是一种欺骗。所以会有以下判断:

cpp 复制代码
 if (!txindex.vSpent[prevout.n].IsNull())

非空,也就是这笔vout对应的vSpent已经有指向一个tx了。

至此,都通过后,你的引用成立,所以我们来标记一下,建立引用关系。

cpp 复制代码
    txindex.vSpent[prevout.n] = posThisTx;

将txindex下的对应那个vSpent标记为已花费。

注意啊,这里的是改的txindex索引数据,也就是改数据库的索引记录。并不是之前的区块数据啊,区块里也并没有vSpent这个参数,也不可能让你改。那不乱套了吗。

这套数据是单独维护的,用于快速验证交易,但是会有一个问题,如果是单独维护的这个vSpent造假怎么办?如果是假的,但是这个由于只是你本地的数据,你只能欺骗自己的节点,最终链上数据不一致。这个是无法成功的。

6.fMiner

然后是这句:

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

从执行语句可以看到,修改了txindex后,一个是将它写进数据库,一个是将它写进mapTestPool。

可以分析出分什么要这样,跟上面的一样的原因,如果引用的是同一个区块,那么数据库里还没有,是不能写进数据库的,临时写入到mapTestPool里。

但是这里又是依据什么来判断的呢?

可以看到判断了两个参数fBlock和fMiner,前面我们说过,其它的地方也会调用这个ConnectInputs函数,具体是哪里了,比如这个bool 类型fMiner标志,表示在挖矿,那么我们可以去挖矿函数看一下。

BitcoinMiner函数里,有这么一句:

cpp 复制代码
       map<uint256, CTxIndex> mapTestPoolTmp(mapTestPool);
       if (!tx.ConnectInputs(txdb, mapTestPoolTmp, CDiskTxPos(1,1,1), 0, nFees, false, true, nMinFee))
           continue;
       swap(mapTestPool, mapTestPoolTmp);

第六,第七的参数,传的是false,true,就会写进mapTestPool,说明此区块还在挖矿,上一级已经判断好了,此块还没写进数据库(数据库里没有),所以写到mapTestpool里。

如果是ConnectBlock里调用,则传的是true,false。

我们这里现在主要关注ConnectBlock的逻辑。

写完后,然后会执行这个计数:

cpp 复制代码
   nValueIn += txPrev.vout[prevout.n].nValue;

将这笔tx下的每笔vin,引用的所有vout的值进行累积,因为引用的值不是你能调整的,所以可能发生找零的事,我们需要计算引用的总值是多少,以便后续计算。

7.nTxFee

循环完,得到总值后就会进行这个判断:

cpp 复制代码
        // 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;

这里是计算这笔交易的手续费是给矿工的,计算nTxFee,当得到了nValueIn后,就是你引用的金额,减去实际支付的金额,通过GetValueOut函数获得。

比如,你引用了1btc,要支付给小张0.8btc,那么还剩0.2btc,所以不小于0,第一个判断不成立。

如果小于0,则你没有足够的钱来支付手续费,就会报错。这就是第一个判断的作用。

但这里还有一个问题,那找零呢?所以上面的只是简单的说明,只是为了让你快速理解nTxFee<0的意思。

其实GetValueOut()获取的是所有的vout,当然也包括找零。

那么现在再加上找零,实际是这样的,你引用了1btc,要支付给小张0.8btc,然后找零给自己0.19btc。这样nTxFee就是0.01btc>0,第一个逻辑过了。

但是不是大于0就可以了,矿工还有个最低手续费要求,你不能低于这个手续费,否则,你给它一毛钱也行?那是不可能的。

手续查账不能小于nMinFee,这个参数是函数传进来的,在挖矿时计算,会传入,具体算法,等到了挖矿函数再说明。如果不是挖矿时,说明是接受的别人广播的块,所以不用传入参数,因为已经计算判断好了,在这里不需要nMinFee限制(函数声明不传参默认为0),你只需要计算手续费就行。

8.nFee

接下来是:

cpp 复制代码
nFees += nTxFee;

累积手续费,为什么要这样,因为这只是一笔tx的手续费,矿工的手续费是按一个区块收取的,打包也是按区块打包的。所以这一切是非常的合理。计算这个区块最终能获得多少手续费。

9.写入CTxIndex

手续费算好后,最后,执行这段语句:

cpp 复制代码
    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());
    }

这里就是将此tx的交易索引,根据情况,如果非挖矿,就写进数据库里面,会在AddTxIndex里面根据传进来的参数,构建CTxIndex,然后写入数据库,代码如下:

cpp 复制代码
bool CTxDB::AddTxIndex(const CTransaction& tx, const CDiskTxPos& pos, int nHeight)
{
    assert(!fClient);

    // Add to tx index
    uint256 hash = tx.GetHash();
    CTxIndex txindex(pos, tx.vout.size());
    return Write(make_pair(string("tx"), hash), txindex);
}

如果是fMiner还在挖矿,这个索引临时存到mapTestPool里,并且它的位置是指定的格式,是

CDiskTxPos(1,1,1),完整代码如下:

cpp 复制代码
    else if (fMiner)
    {
        // Add transaction to test pool
        mapTestPool[GetHash()] = CTxIndex(CDiskTxPos(1,1,1), vout.size());
    }

至此,整个ConnectInputs函数代码逻辑我们都了解了,那么回到开头的第一句:

cpp 复制代码
 if (!IsCoinBase())

为什么会有这个判断,都清楚了,因为CoinBase交易是没有引入的上级vout的,所以也不用构建引用的索引,和验证那笔vout是不是属于你的这些逻辑。

好了,那么接下来,我们看一下,如果我们只要写入交易索引该怎么做?

目前只保留两个东西就可以了,一个是在ConnectBlock函数,循环遍历vtx,调用ConnectInputs,一个是在ConnectInputs函数,只调用这个函数:

cpp 复制代码
 if (!txdb.AddTxIndex(*this, posThisTx, nHeight))

直接写数据库就行了,因为我们只有一个区块,只测试写交易索引,所以不用验证,以及维护引用的区块交易索引等这些事。

但是我们返回上级ConnectBlock函数可以看到这样遍历写完之后(跳回CBlock),还会有一些操作,是这部分代码:

cpp 复制代码
    if (vtx[0].GetValueOut() > GetBlockValue(nFees))
        return false;

    // Update block index on disk without changing it in memory.
    // The memory index structure will be changed after the db commits.
    if (pindex->pprev)
    {
        CDiskBlockIndex blockindexPrev(pindex->pprev);
        blockindexPrev.hashNext = pindex->GetBlockHash();
        txdb.WriteBlockIndex(blockindexPrev);
    }

    // Watch for transactions paying to me
    foreach(CTransaction& tx, vtx)
        AddToWalletIfMine(tx, this);

    return true;

我们来一个一个说明。

我们知道一个tx的GetValueOut就是获得这个tx下的vout总和,而vtx[0],就是第一笔交易,就支付给矿工的,也coinbase,那么vtx[0].GetValueOut就是获取支付给矿工的费用(包含挖矿奖励和手续费)

10.GetBlcokValue

那么GetBlcokValue(nFees)获取的是什么呢?需要看具体的代码,如下:

cpp 复制代码
int64 CBlock::GetBlockValue(int64 nFees) const
{
    int64 nSubsidy = 50 * COIN;

    // Subsidy is cut in half every 4 years
    nSubsidy >>= (nBestHeight / 210000);

    return nSubsidy + nFees;
}

看到没有,这个nFees就是之前计算出的整个区块的交易手续费。

然后在这里还要加上矿工基础奖励,这里有这样一个计算公式:

cpp 复制代码
   // Subsidy is cut in half every 4 years
    nSubsidy >>= (nBestHeight / 210000);

根据当前区块高度nBestHeight来计算的,初始值是50个币,每4年减半就是这么来的。

这就是这个函数获得的值。

然后回到上面的if逻辑,就是为了防止矿工给自己多vout,胡乱构造,所以你的vout必须符合计算规则。如果不符合,直接返回false。

11.hashNext

然后是这里,交易索引完成后,就会解决区块索引的一些事:

cpp 复制代码
  if (pindex->pprev)
  {
      CDiskBlockIndex blockindexPrev(pindex->pprev);
      blockindexPrev.hashNext = pindex->GetBlockHash();
      txdb.WriteBlockIndex(blockindexPrev);
  }

就是这个区块索引的父索引存在,则将父索引的hashNext指向自己的哈希,设置一下关系。

更改完后,将父索引写入数据库。(注意这里用的是CDiskBlockIndex,hashNext是实值,所以只写磁盘(数据库)),非写内存索引。

12.AddToWalletfMine

然后是这部分:

cpp 复制代码
    // Watch for transactions paying to me
    foreach(CTransaction& tx, vtx)
        AddToWalletIfMine(tx, this);

当一个区块成功连接到区块链后,就会遍历这个区块的每一笔交易,查看是否有支付给我的交易,如果有,就会记录到钱包数据库里。

这就是之前说的,两部分交易数据,钱包有一份单独的关于我的交易数据,CTxDB里也有一份(所有人的),就是在这部分代码写入的。

好,至此ConnectBlock函数的代码逻辑也了解完了。

在下一章中,我们把这些函数复制到我们的项目,并修改代码,以测试一下读写交易索引。

相关推荐
曦月逸霜6 小时前
区块链技术与应用学习笔记(持续更新中)
笔记·学习·区块链
Web3VentureView9 小时前
SYNBO维港私享局:在香港Web3嘉年华最后一天,打开链上一级市场的共识现场
人工智能·web3·区块链·加密货币·synbo
TechubNews1 天前
专访新火集团首席经济学家付鹏:解读比特币资产属性、香港楼市与普通人理财建议——Techub News对话实录
人工智能·区块链
王苏安说钢材A1 天前
无锡佳钛合不锈钢有限公司不锈钢焊管厂家
区块链
财迅通Ai1 天前
能源板块强势领涨,汇添富能源ETF(159930.SZ)单日大涨3.41%
区块链·能源·中国神华·陕西煤业
Web3VentureView1 天前
SYNBO亮相香港《前瞻》活动,联手HashKey共筑链上原生一级市场新范式
人工智能·web3·区块链·加密货币·synbo
每日综合1 天前
Web3 多链时代,安全与体验如何兼得?UKey Wallet 的“解题思路”
安全·web3·区块链
MicroTech20251 天前
微算法科技(NASDAQ :MLGO)基于量子隐形传态的区块链共识机制:量子时代下的信任重构
科技·重构·区块链
TechubNews2 天前
Base 发布首个独立 OP Stack 框架的网络升级 Azul,将是 L2 自主迭代的开端?
大数据·网络·人工智能·区块链·能源