二十三.交易数据之签名(2)--选币逻辑

1.GetCredit

我们来接上一章代码,如果这笔交易符合要求,即未花费,则调用下面语句进行查找,获取金额:

cpp 复制代码
            int64 n = pcoin->GetCredit();
            if (n <= 0)
                continue;

调用了这笔CWalletTx里的GetCredit(父类CMerkleTx)函数:

cpp 复制代码
    int64 GetCredit() const
    {
        // Must wait until coinbase is safely deep enough in the chain before valuing it
        if (IsCoinBase() && GetBlocksToMaturity() > 0)
            return 0;
        return CTransaction::GetCredit();
    }

在这里判断是你的引用来源是否为挖矿所得,如果是,则要100个区块之后,否则return 0;这笔交易无法被引用。

然后就是调用父类(CTransaction)的GetCredit来获取值,此函代码如下:

cpp 复制代码
   int64 GetCredit() const
   {
       int64 nCredit = 0;
       foreach(const CTxOut& txout, vout)
           nCredit += txout.GetCredit();
       return nCredit;
   }

我们可以看到,遍历了这笔tx的每一个vout,然后调用txout.GetCredit(),获取值,累加。

我们来看看这个CTxOut类里的函数代码:

cpp 复制代码
   class CTxOut
   {
    public:
    int64 nValue;
    CScript scriptPubKey;

    int64 GetCredit() const
    {
        if (IsMine())
            return nValue;
        return 0;
    }
2.IsMine

看到没有,调用了IsMine这个函数,判断这笔vout是否是转给我的,如果是则返回数额(nValue)

而这个IsMine是怎么判断的呢,它从scriptPubKey里解析出公钥,然后,从mapKeys(P2PK)和mapPubKeys(P2PKH Hash160)里查找,如果有对应的,即我们知道私钥,就判断是属于我的。

这两个变量之前我们学钱包那章的时候,知道就是存储关于我的钱包账号相关的公钥。

所以在这里就用上了。好了,具体代码就不细说了,有点复杂,通过构建签名成功的方式判断,不是直接判断的。就是用传入的scriptPubKey去构建签名,如果能签名成功,那肯定你是有这个账号相关的密钥。所以从侧面判断出这笔交易(vout)就是你的,你拥有对这笔交易的处理权(因为密钥你都有了)。从这里可以看出,如果你转给别人50btc,但你钱包下的单个地址不够,只有25btc,但如果你的钱包还有一个地址,有30btc,它会把这两个地址都引用的,凑够50btc,即一个tx下,不管是vin还是vout可以有不同的地址。那如何保证不会引起混乱呢,是签名,签名保证了,我引用多少,支出多少,不可改变,一变签名就错。

3.选币逻辑(挑选归类)

好了,跳回到之前的代码,我们来看一下后面的代码:

cpp 复制代码
       int64 n = pcoin->GetCredit();
       if (n <= 0)
           continue;
       if (n < nTargetValue)
       {
           vValue.push_back(make_pair(n, pcoin));
           nTotalLower += n;
       }
       else if (n == nTargetValue)
       {
           setCoinsRet.insert(pcoin);
           return true;
       }
       else if (n < nLowestLarger)
       {
           nLowestLarger = n;
           pcoinLowestLarger = pcoin;
       }

调用GetCredit获取整笔的tx下的vout金额后,如果为0或小于0,那不用说了,失败了或者没有,肯定还得继续寻交易,所以调用了continue;

(从上面代码可以知道这个n,一旦获取,就是针对整笔tx的,这个和之前的fSpent标记对应上了)

然后是这个代码:

cpp 复制代码
       if (n < nTargetValue)
       {
           vValue.push_back(make_pair(n, pcoin));
           nTotalLower += n;
       }

如果这个n有值,但小于目标值,就是我要转账的金额,则将这笔值(n)和tx(pcoin)加入到vValue里,方便后续使用,并且累加值到nTotalLower里。

但是你在这里会看到,并没有nTotalLower>=nTargetValue的判断,从而跳出循环。而是会一直添加。

只有一个这样的跳出判断:

cpp 复制代码
       else if (n == nTargetValue)
       {
           setCoinsRet.insert(pcoin);
           return true;
       }

相等就跳出,但我们知道vout不是我们能规定的,所以恰好等于nTargetValue这种概率比较小。

所以这些代码是在干什么呢,包括后面的这个代码:

cpp 复制代码
       else if (n < nLowestLarger)
       {
           nLowestLarger = n;
           pcoinLowestLarger = pcoin;
       }

其实他是在挑选该怎么最好的构建这笔交易对应的金额vout,它不是简单的随便挑选,只要累加到符合金额的交易就构建交易了,它需要选择,所以它才会这样。

所以当找到一个tx正好跟你转账的金额相等,那么就直接用这个tx,这是最完美的,不用找零,所以直接退出。如果没找到,则选择拼凑交易,但这个拼凑也不是随便选的,也有逻辑,所以需要全部加到vValue里,然后再来挑选。

那么还有一个逻辑判断即:n<nLowestLarger

这里是判断n值大于目标金额,即需要找零的,处理逻辑。但为什么不是这样判断呢?

即n>nTargetValue呢,因为前面两个已经帮你判断了,n即不是小于nTargetValue,也不是等于nTargetValue,那么走到第三步,那肯定是大于nTargetValue咯。

所以这里的处理是记录这个大于的值,即存到nLowestLarger。然后继续,如果找到比这个大于的值更小的,就更新这个nLowestLarger,所以它也不是一找到大于的就用这个vout了。而是找到那个最小的大于n的vout。就像你花费8块钱,你手里有张10块的,20块的,100块的。你肯定一般是拿出10块的张支付然后找零。

根据上面代码的执行逻辑,我们可以推断出大概的选币逻辑,优先相等,然后拼凑,最后选找零的vout。其实跟我们自然人的支付是一样的逻辑,商家收我们10块,如果你口袋中,有一张10块,两张5块,一张20,你选择怎么给他?如果去掉那张10块的,你又会怎么选择?是一样的道理。

(当然这个拼凑也允许找零的,比如5元和6元,假设有6元面值,你还是会选择拼凑,更多的细节,我们可以看后续的代码逻辑)

我们来看接下来的代码:

cpp 复制代码
  if (nTotalLower < nTargetValue)
  {
      if (pcoinLowestLarger == NULL)
          return false;
      setCoinsRet.insert(pcoinLowestLarger);
      return true;
  }

这里的判断是当小额组合(拼凑)不够支付时,如果大额没为null,就是有大额,则选择大额。插入到setCoinsRet里,然后返回真,选币成功,注意这里的setCoinsRet,在之前如果有等额,也会被插入,并返回真,选币成功。这是两种选币成功的逻辑。从:

if (nTotalLower < nTargetValue)

这个判断可以看到,大额支付,是放在最后备选的。如果小额够,是优先选择小额支付,证明我们之前的推断没错。

4.小额组合选币算法

好,如果继续往下执行的话,那说明是进入小额组合选币的逻辑了,采取这种方法,因为在上面,已经判断了,如果小额不够,然后大额又没有,(到这里默认等额也没有)就会返回假(你的余额不够)。那么如果还能继续往下执行的话(说明没进入到上面判断的处理逻辑,也就是小额足够):

进入到了小额组合选币的处理逻辑,从小额集合中,挑选满足这笔支付金额的最优解组合,即从一堆零钱(UTXO)里挑出一组,尽量满足**nTargetValue,**但不要太超额。

详细代码:

cpp 复制代码
// Solve subset sum by stochastic approximation
// 使用随机近似算法解决"子集和"问题(Subset Sum Problem)

sort(vValue.rbegin(), vValue.rend());     
// 将所有小于目标金额的 UTXO 按金额从大到小排序
// 目的是让大额的小币优先被尝试,提高找到较优组合的概率

vector<char> vfIncluded;                    // 当前尝试的选中标记(临时使用)
vector<char> vfBest(vValue.size(), true);   // 记录到目前为止最好的选中组合(默认全选)
int64 nBest = nTotalLower;                  // 记录当前最好的总金额(初始为所有小额总和)

// 进行最多 1000 次随机尝试,目标是找到总和最接近 nTargetValue 的组合
for (int nRep = 0; nRep < 1000 && nBest != nTargetValue; nRep++)
{
    vfIncluded.assign(vValue.size(), false);   // 重置本次尝试的选中状态
    int64 nTotal = 0;                          // 本次尝试累加的金额
    bool fReachedTarget = false;               // 是否已经达到或超过目标金额

    // 进行两轮尝试(nPass = 0 和 nPass = 1),增加找到更好组合的概率
    for (int nPass = 0; nPass < 2 && !fReachedTarget; nPass++)
    {
        for (int i = 0; i < vValue.size(); i++)
        {
            // 第一轮:随机决定是否纳入该 UTXO
            // 第二轮:只尝试之前未纳入的 UTXO(弥补第一轮遗漏)
            if (nPass == 0 ? rand() % 2 : !vfIncluded[i])
            {
                nTotal += vValue[i].first;        // 尝试加入当前 UTXO
                vfIncluded[i] = true;

                // 如果总金额已经达到或超过目标值
                if (nTotal >= nTargetValue)
                {
                    fReachedTarget = true;

                    // 如果本次组合比之前记录的最好结果"更接近"目标值,则更新最佳记录
                    if (nTotal < nBest)
                    {
                        nBest = nTotal;           // 更新最好的总金额
                        vfBest = vfIncluded;      // 保存当前最好的选中组合
                    }

                    // 回溯:撤销刚才加入的这个 UTXO,继续尝试其他组合
                    nTotal -= vValue[i].first;
                    vfIncluded[i] = false;
                }
            }
        }
    }
}

// ====================== 最终决策:小额组合 vs 单个最小大额 ======================

// 如果存在大额 UTXO,且使用它产生的找零金额 ≤ 当前小额组合产生的找零金额
// 则优先选择使用单个最小的大额 UTXO(因为找零更少,通常更好)
if (pcoinLowestLarger && nLowestLarger - nTargetValue <= nBest - nTargetValue)
{
    setCoinsRet.insert(pcoinLowestLarger);     // 直接使用这个最小的大额币
}
else
{
    // 否则使用小额组合中找到的最佳子集
    for (int i = 0; i < vValue.size(); i++)
        if (vfBest[i])
            setCoinsRet.insert(vValue[i].second);   // 把选中的小额 UTXO 加入结果集

    //// debug print
    // 打印调试信息,显示最终选择的组合及总金额
    printf("SelectCoins() best subset: ");
    for (int i = 0; i < vValue.size(); i++)
        if (vfBest[i])
            printf("%s ", FormatMoney(vValue[i].first).c_str());
    printf("total %s\n", FormatMoney(nBest).c_str());
}

return true;     // 选币成功,返回 true
5.sort

我们先来看第一句:

cpp 复制代码
 // Solve subset sum by stochastic approximation
 sort(vValue.rbegin(), vValue.rend());

sort这个函数,将vValue数组里面的金额排序,即从大到小排序。然后随机尝试优先从大额里组合成支付金额,注意这里是随机尝试,为什么要这样,因为如果完整的遍历计算比较每个组合,找到最好的那个组合,几乎不可能完成,需要大量的计算,所以采用的是随机查找,大概有个差不多好的就行。当然也不是纯随机,那样效率也低,是一种特殊的随机查找算法。

具体看代码了解。

6.随机1000次

我们来看这个循环代码:

cpp 复制代码
for (int nRep = 0; nRep < 1000 && nBest != nTargetValue; nRep++)
{

这里的意思,是进行随机1000次尝试,如果组合正好等于目标值,则停止查找,不用找零,这是最好的方案,如果没有,则找满1000次。然后保留那个最好的组合,即找零最少(即 nTotal 最接近 nTargetValue 且 ≥ nTargetValue)的那一个组合。

我们看具体的代码,实际代码有点复杂,因为它里面又细分了两次随机查找(正反),这里我把代码简化一下,理解了后,再把去掉的代码做说明,这样便于理解一点:

7.随机组合

我们来看进入循环后:

cpp 复制代码
    for (int i = 0; i < vValue.size(); i++)

开始遍历vValue中的每一个coin的值,然后挑选是通过随机的方式:

cpp 复制代码
 if (nPass == 0 ? rand() % 2 : !vfIncluded[i])

当然这里写这个三元表达试,是为了正反两轮随机的,这里假设没有,只有一轮。

那么你可以理解为这样:

cpp 复制代码
 if (rand() % 2)

那么if里的代码,理论上来说,有2分之一的概率会被执行,也就是随机选中这笔小额支付(vValue):

cpp 复制代码
       {
           nTotal += vValue[i].first;
           vfIncluded[i] = true;
           if (nTotal >= nTargetValue)
           {
               fReachedTarget = true;
               if (nTotal < nBest)
               {
                   nBest = nTotal;
                   vfBest = vfIncluded;
               }
               nTotal -= vValue[i].first;
               vfIncluded[i] = false;
           }
       }

将vValue[i].first的值进行累加到nTotal,然后累加到符合目标值后,即nTotal>=nTargetValuer后。

8.回退机制

则将此nTotal值记录在nBest里,但这一轮随机并不算完,它还会回退最后一个值,然后再继续找,直到遍历完整个vValue,这当中如果有符合要求,并小于上一轮的nTtotal,则用这个组合替代上一个,即这段代码逻辑:

cpp 复制代码
         if (nTotal < nBest)
          {
            nBest = nTotal;
            ...
          }

注意,这里只回退一个值,并不是所有即:

cpp 复制代码
           nTotal -= vValue[i].first;
               vfIncluded[i] = false;

好,这样遍历完vValue后,还不算完,还得将之前没选中的vValue,又按照上面的方法查找组合一下,如果能找到最优解,则进行代替。这样才算是完整的一次随机组合,找到一个最优的组合。

然后这样的随机,上面有个更大的for语句,还要进行1000次,从这1000次里找到的最优组合,就是最终的组合了。

9.组合记录

之前我们并没有看到怎么记录组合的,那它是怎么记录的呢?我们来看这两个变量:

cpp 复制代码
 vector<char> vfIncluded;
 vector<char> vfBest(vValue.size(), true);

是一个数组,它们的大小是跟vValue一样的,并且是bool值,当元素设置为真时,即代表这个对应的coin值是组合中的一员。也就是,你可以遍历vfbest,看哪些元素为真,假设vfBest[2],vfBest[7],vfBest[8]为真,那么相应的vValue[2],vValue[7],vValue[8]就是一个最优的组合。

是这样记录的,我们可以在代码中看到:

cpp 复制代码
        if (nPass == 0 ? rand() % 2 : !vfIncluded[i])
        {
            nTotal += vValue[i].first;
            vfIncluded[i] = true;
            if (nTotal >= nTargetValue)
            {

每当选中一个coin值,就是将其对应的vfIncluded[i] = true;设置为真(这个也可用于后面的正反选取,如果为假则表示这一轮没选中,那么下一轮就使用这些没选中的做组合)

然后当vfIncluded做为当前最优的组合时,就会被赋予给vfBest:

cpp 复制代码
        if (nTotal < nBest)
        {
            nBest = nTotal;
            vfBest = vfIncluded;
        }

所以vfBest记录了当前最优的组合。

10.正反选取

在正向选一轮之后,还会反向选一轮,就是上次随机(rand()%2),没选中的再进行一轮组合选取。

关键点在这个三元运算符:

cpp 复制代码
     if (nPass == 0 ? rand() % 2 : !vfIncluded[i])
     {

当反向选取后,也就是第二轮,则nPass为1,那么nPass==0为假,则会执行后一个表达式,即!vfIncluded[i],也即,vfIncluded[i]为假,里面的代码才会被执行,就是没选中的那些,标记为假的vfIncluded[i]。当然由于回退机制,第一轮组合的最后一个并不在此列。

当然有的人可能会有疑问,如果只回退一个,那么目标值是100元,那么如果前面有900个1毛的,凑成90元,然后只盯着最后一个回退组合,那不是很粗糙?这个不用担心,别忘了前面的从大到小的排序。所以这个情况不可能发生。如果有最优解的话,但是这样的话,从大到小,会不会又发生了直接一张90元+50元,90元+80元,90元+20元,而有一种50+50元不会被选到。这个也不用担心,因为我们是随机的,忘记了那个rand()%2吗?所以50+50也有可能被抽中,而且这样的随机我们足足进行了1000次。就是为了尽可能的找到最优解。

所以这种是按照大小排序后再随机,不是完全的随机,目的是为了尽可能找到最合适的那个组合。

10.1余额等额

但是这里还有一个问题,如果我的余额为50btc,我正好要转账别人50btc,而我的这50btc分布在100个vout里,按照上面那种随机二分之一的概率选vout,那么必须是100次连续选中,才能凑出这个组合,这个概率为2的100次方之一,而随机1000次,根本不可能正好生成这个组合。

别急,这种漏洞是不会有的,因为我们漏掉了一些细节。

即,第一次选择(正向),如果没凑足目标值,这下面的变量是不会被清零的:

cpp 复制代码
  nTotal += vValue[i].first;
  vfIncluded[i] = true;

nTotal会累加到第二次选择中,然后接着累加,直到凑够目标值。关键点在于nTotal并不会被重新赋值。

11.小额和大额

好,小额组合完成后,到了这里,也并不是最终的获胜者,如果你同样也是找零的,那么就是和大额的比较一下,谁找零的少就用谁,也就是,我手里一堆零钱,我随便试着凑钱,凑到差不多就停,试1000次,选一个最不浪费的方案。如果发现直接用一张大钞更划算,那就别折腾零钱了。"

所以你会看到以下判断:

cpp 复制代码
    // If the next larger is still closer, return it
    if (pcoinLowestLarger && nLowestLarger - nTargetValue <= nBest - nTargetValue)
        setCoinsRet.insert(pcoinLowestLarger);
    else
    {
        for (int i = 0; i < vValue.size(); i++)
            if (vfBest[i])
                setCoinsRet.insert(vValue[i].second);

        //// debug print
        printf("SelectCoins() best subset: ");
        for (int i = 0; i < vValue.size(); i++)
            if (vfBest[i])
                printf("%s ", FormatMoney(vValue[i].first).c_str());
        printf("total %s\n", FormatMoney(nBest).c_str());
    }

    return true;

如果大额不为空(如有),判断大额的找零比组合的还少,则直接用大额的,即:

cpp 复制代码
  setCoinsRet.insert(pcoinLowestLarger);

否则 ,将小额的coin全插入setCoinsRet

cpp 复制代码
    for (int i = 0; i < vValue.size(); i++)
            if (vfBest[i])
                setCoinsRet.insert(vValue[i].second);
12.setCoinsRet

整个代码看完后,我们可以知道,如果选币成功,不管选择哪种支付方式,小额组合,等额,还是大额找零,都会将它们存到setCoinsRet里。

那么这个函数:

cpp 复制代码
bool SelectCoins(int64 nTargetValue, set<CWalletTx*>& setCoinsRet)

在上一级调用中:

cpp 复制代码
             // Choose coins to use
             set<CWalletTx*> setCoins;
             if (!SelectCoins(nValue, setCoins))
                 return false;

关于setCoins获得了什么,以及怎么处理,现在我们就很清楚了,现在我们来看上一级后续的处理代码,就会简单一些。下一章中说明后续代码。

相关推荐
zhglhy6 小时前
交易支付/证券/数字货币交易所交易引擎核心功能对比
区块链
长安链开源社区11 小时前
学者观察 | 基于区块链的隐私计算技术——北京理工大学教授祝烈煌
运维·区块链
链上日记15 小时前
WEEX行业视角:从近期安全事件看,2026 年或成为行业安全分水岭
大数据·安全·区块链
m0_3801671416 小时前
如何用 CoinGlass API 构建交易信号系统
人工智能·ai·区块链
m0_3801671416 小时前
如何用订单簿数据判断真假突破(OrderBook 实战)
大数据·人工智能·区块链
2301_7760452317 小时前
什么叫流动性:数字货币与美股市场解读
人工智能·区块链
长安链开源社区1 天前
长安链2.3.8生产版本发布,安全、开放、灵活的企业级区块链底座
安全·区块链
程序员李程峰1 天前
基础知识④链和代币之间的关系
web3·去中心化·区块链·智能合约·同态加密·共识算法·信任链
程序员李程峰1 天前
基础知识⑤ERC-20、BEP-20 和TRC-20 这三种流行的加密代币标准
web3·去中心化·区块链·智能合约·同态加密·共识算法·信任链