NEO从源码分析看UTXO交易

0x00 前言

社区大佬:“交易是操作区块链的唯一方式。”

0x01 交易类型

在NEO中,几乎除了共识之外的所有的对区块链的操作都是一种“交易”,甚至在“交易”面前,合约都只是一个小弟。交易类型的定义在Core中的TransactionType中:

源码位置: neo/Core/TransactionType

        /// <summary>
        /// 用于分配字节费的特殊交易
        /// </summary>
        [ReflectionCache(typeof(MinerTransaction))]
        MinerTransaction = 0x00,

        /// <summary>
        /// 用于分发资产的特殊交易
        /// </summary>
        [ReflectionCache(typeof(IssueTransaction))]
        IssueTransaction = 0x01,
        
        [ReflectionCache(typeof(ClaimTransaction))]
        ClaimTransaction = 0x02,

        /// <summary>
        /// 用于报名成为记账候选人的特殊交易
        /// </summary>
        [ReflectionCache(typeof(EnrollmentTransaction))]
        EnrollmentTransaction = 0x20,

        /// <summary>
        /// 用于资产登记的特殊交易
        /// </summary>
        [ReflectionCache(typeof(RegisterTransaction))]
        RegisterTransaction = 0x40,

        /// <summary>
        /// 合约交易,这是最常用的一种交易
        /// </summary>
        [ReflectionCache(typeof(ContractTransaction))]
        ContractTransaction = 0x80,

        /// <summary>
        /// 投票合约 //votingDialog
        /// </summary>
        [ReflectionCache(typeof(StateTransaction))]
        StateTransaction = 0x90,
        /// <summary>
        /// Publish scripts to the blockchain for being invoked later.
        /// </summary>
        [ReflectionCache(typeof(PublishTransaction))]
        PublishTransaction = 0xd0,
        /// <summary>
        /// 调用合约   GUI invocatransactiondialog
        /// </summary>
        [ReflectionCache(typeof(InvocationTransaction))]
        InvocationTransaction = 0xd1

这些交易不仅名目繁多,而且实际功能和我“以为”的还有些不同,为了分别搞清楚每种交易是做什么的,我几乎又把NEO和GUI的源码翻了个遍。

  • MinerTransaction: 矿工手续费交易,在新block创建的时候由_议长_添加这笔特殊交易。使用位置:neo/Consensus/ConsensusService.cs/CreateMinerTransaction。
  • IssueTransaction:资产分发交易,用于在新资产创建的时候对新资产进行分发的特殊交易。使用位置:neo/Core/BlockChain.cs/GenesisBlock。
  • ClaimTransaction:NEOGAS提取交易,用于提取GAS。在GUI的主界面里有个“高级”选项卡,点击后下拉列表里有个提取GAS,调用的就是这个交易。使用位置:neo-gui/UI/ClaimForm.cs/button1_Click。
  • EnrollmentTransaction:用于申请成为记账人,也就是议员的交易。这个交易GUI种并没有接入接口,也就是说使用GUI是没办法申请成为记账人的。悄悄说:想成为记账人,至少要拥有价值几个亿的NEO才行,所以如果你是记账人,应该不会吝啬向我的NEO账户转几个GAS吧。
  • RegisterTransaction:注册新资产的交易,在我之前的博客《从NEO源码分析看UTXO资产》的博客种也提到了这个,NEO和GAS就是通过这个交易在创世区块被创造出来的。但是有意思的是,这个交易只在注册NEO和GAS的时候用到了,GUI里注册新资产使用的方式居然是进行合约级的系统调用,通过调用“Neo.Asset.Create”来创建新资产。
  • ContractTransaction:我感觉最容易搞混的就是这个合约交易了。我一直以为这个应该是发布应用合约的时候用的,毕竟叫合约交易嘛,然鹅,too young too simple, 这个交易是我们转账的时候调用的,没错,每一笔转账都是一种合约交易,GUI的转账历史记录里可以看到交易类型,基本上都是这个合约交易。使用位置:neo/Wallets/Wallet.cs/MakeTransaction。
  • StateTransaction:如白皮书所说,NEO网络的_议员_是通过选举产生的,也就是说我们广大老百姓是可以决定上层建筑结构,参与社会主义现代化建设的,这是NEO给我们的权力。在GUI中可以右键账户,然后选择投票来参与选举投票。使用位置:neo-gui/UI/VotingDialog.cs/GetTransaction。
  • PublishTransaction:这个交易才是货真价实的_应用合约_发布交易,也就是说,理论上我们要发布合约到区块链上,都需要通过这个PublishTransaction类型的交易把我们的合约广播出去才行。然而呢?现实是残酷的,GUI并没有调用这个交易,发布新合约依然是进行合约级的系统调用,通过调用vm的“Neo.Contract.Create”API来创建新合约。部署应用合约的代码位置:neo-gui/UI/DeployContractDialog.cs/GetTransaction。
  • InvocationTransaction:将我们可怜的RegisterTransaction和PublishTransaction的作用替代的万恶交易类型,合约调用交易。我们在成功部署合约之后的那笔交易,在GUI的交易历史的类型栏里,赫然写着InvocationTransaction。构造交易的方式更是简单粗暴,就是直接调用EmitSysCall然后传入参数完事。使用位置:neo-gui/UI/AssetRegisterDialog.cs/GetTransaction。

以上就是NEO中的9种交易类型,也基本上就是除了 议长 创建新区块之外所有的可以影响到区块链生成的操作手段。虽然我将每种交易类型都大概分析了一下,但是还是有些问题没有搞清楚,比如为什么创建新资产和部署合约的时候是进行系统调用而不是发送相应的交易,我从源码里没找到答案。

0x02 余额

从我的题目就可以看出,我并不准备对每一种交易类型都详细介绍,毕竟究其本质,都只是脚本不同的合约而已。我这里要详细分析的是UTXO交易,就是合约交易,也是我们日常在NEO网络上进行的转账操作。 在社区里经常听到有人在问:“NEO账户究竟是什么?“NEO和GAS余额是怎么来的?”“交易究竟转的是什么?”。我感觉这些问题我之前都有过,就是首先对 代币 这种概念不是很清晰,其次对虚拟的_账户_更是无法理解。其实在最初开始看源码,也是希望能在这个过程中解决自己对于区块链和智能合约认知上的不足。幸运的是,随着一个个模块看下来,对于NEO整体的系统架构和运行原理已经基本可以说是了然于胸。可见Linux之父那句:”Talk is cheap,show me your code.“(别逼逼,看代码),还是很有道理的。 对不起,我逼逼的有点多。

要理解余额的计算原理,首先还是要理解UTXO交易模型,这个东西社区大佬李总在群里发布了一系列的讲解视频,感兴趣的可以去膜一下。《Mastering BitCoin》的第六章 Transaction 也对此进行了详细的讲解,想看的同学可以在文末找到下载连接。 计算余额的代码在wallet类中。

源码位置:neo/Wallets/Wallet

public Fixed8 GetBalance(UInt256 asset_id)
{
    return GetCoins(GetAccounts().Select(p => p.ScriptHash))
              .Where(p => !p.State.HasFlag(CoinState.Spent)  //未花费状态
                                  && p.Output.AssetId.Equals(asset_id)) //资产类型
              .Sum(p => p.Output.Value);
}

其实从这里就可以很清晰的看出来这个余额的计算过程了,就是将与地址对应的Output进行遍历,将与指定资产类型相同且状态不是spent(Spent代表已转出)的值相加。更加直白通俗的解释就是,加入每一个指向你账户的Output相当于给你的一笔钱,所有未被花费的Output加起来,就是你现在的余额。

0x03 新交易

在NEO中,转账的时候需要构造一个Transaction对象,这个对象中需要指定资产类型、转账数额、资产源(Output),目标地址,见证人(Witness)。由于GUI中的转账这块是可以同时从当前钱包的多个地址中构造一个交易的,比较复杂,我这里用我小程序的代码来做讲解,基本原理是一样的。

轻钱包关于交易处理的这块的代码是改自NEL的TS轻钱包,不过去除了TS原本代码中关于界面的代码,然后重新封装为js模块,相当于GUI转账功能魔鬼瘦身版本之后又进行的地狱级瘦身,代码量极大减少。 小程序转账的入口在send.wpy界面中:

源码位置:neowalletforwechat/src/pages/send.wpy/OnSend

//交易金额
var count = NEL.neo.Fixed8.parse(that.amount + '');
//资产种类
let coin = that.checked === 'GAS' ? CoinTool.id_GAS : CoinTool.id_NEO;
wepy.showLoading({ title: '交易生成中' });
//构造交易对象
var tran = CoinTool.makeTran(UTXO.assets, that.targetAddr, coin, count);
// console.log(tran);
//生成交易id 没错是随机数
let randomStr = await Random.getSecureRandom(256);
//添加资产源、资产输出、见证人、签名
const txid = await TransactionTool.setTran(
  tran,
  prikey,
  pubkey,
  randomStr
);

在构造交易对象的时候调用CoinTool的makeTran方法,需要传入四个参数,一个是OutPuts,一个是目标账户,第三个是资产类型,最后一个是资产数量。这个方法对应于neo core中的neo/Wallets/Wallet.cs/MakeTransaction<T>。两者功能基本是一致的。makeTran中的对交易对象的初始化代码如下:

        //新建交易对象
        var tran = new NEL.thinneo.TransAction.Transaction();
        //交易类型为合约交易
        tran.type = NEL.thinneo.TransAction.TransactionType.ContractTransaction;
        tran.version = 0;//0 or 1
        tran.extdata = null;
        tran.attributes = [];
        tran.inputs = [];

在UTXO交易模型中,每笔交易会有一个或者多个资金来源,也就是那些指向当前地址的Output,将这些OutPut作为新交易的输入:

        //交易输入
        for (var i = 0; i < us.length; i++) {
            //构造新的input
            var input = new NEL.thinneo.TransAction.TransactionInput();
            //新交易的prehash指向output
            input.hash = NEL.helper.UintHelper.hexToBytes(us[i].txid).reverse();
            input.index = us[i].n;
            input["_addr"] = us[i].addr;
            //向交易资产来源中添加新的input
            tran.inputs.push(input);
            //计算已添加的资产总量
            count = count.add(us[i].count);
            scraddr = us[i].addr;
            //如果已添加的资产数量大于或等于需要的数量,则不再添加新的
            if (count.compareTo(sendcount) > 0) {
                break;
            }
        }

在一笔交易中,自己账户的output变成新交易的input,然后新交易会指定新的output。通常,一笔交易中除了有一个指向目的账户的output之外,还会有一个用于找零的Output。这里为了方便,我还是讲一个小故事。

  • 第一幕:小明写博客发给社区,每写一篇博客,社区会给小明5个GAS作为奖励,每次社区给小明奖励,小明的账户里就会多一个5GAS的Output。现在小明写了三篇博客,社区也奖励了小明三次。所以小明有三个分别为5GAS的Output。
  • 第二幕:小明希望用写博客挣的GAS给女朋友买化妆品,已知支持GAS支付的化妆品售价为6.8个GAS。
  • 第三幕:一个Output只有5个GAS,明显是不够支付化妆品的,于是小明不得不拿出两个Output来支付。
  • 第四幕:由于每个Output都是不可分割的,就像100块钱一样,你不可以把一张一百的按比例撕开来支付,只能是你给人家100,人家给你找零。Output也是这个道理,你拿出两个Output来支付,那么交易不可能从已有的Output扣出1.8。只能是同时将两个Output都完全设置为Spent,然后给商家一个6.8的OutPut,再给小明一个3.2的Output作为找零。现在小明就只有一个5GAS的output和一个3.2GAS的output了。
  • 第五幕:小明靠自己的努力为女朋友买到了化妆品,女朋友很开心,于是为小明买了新的搓衣板。

UTXO交易其实就是这样的,output是不能分割的,只要被转出,就一起转出,然后再转入一个新的output作为找零。output构造的代码如下:

//输出
if (sendcount.compareTo(NEL.neo.Fixed8.Zero) > 0) {
    var output = new NEL.thinneo.TransAction.TransactionOutput();
    //资产类型
    output.assetId = NEL.helper.UintHelper.hexToBytes(assetid).reverse();
    //交易金额
    output.value = sendcount;
    //目的账户
    output.toAddress = NEL.helper.Helper.GetPublicKeyScriptHash_FromAddress(targetaddr);
    //添加转账交易
    tran.outputs.push(output);
}

//找零
var change = count.subtract(sendcount); //计算找零的额度
if (change.compareTo(NEL.neo.Fixed8.Zero) > 0) {
    var outputchange = new NEL.thinneo.TransAction.TransactionOutput();
    //找零地址设置为自己
    outputchange.toAddress = NEL.helper.Helper.GetPublicKeyScriptHash_FromAddress(scraddr);
    //设置找零额度
    outputchange.value = change;
    //找零资产类型
    outputchange.assetId = NEL.helper.UintHelper.hexToBytes(assetid).reverse();
    //添加找零交易
    tran.outputs.push(outputchange);
}

以上就是构造一笔新交易的过程。基本上一个交易的结构都有了,从哪里来,到哪里去,转多少,都已经构造完成,接下来就需要对这笔交易进行签名。

0x04 签名

与传统的面对面交易不同,在网络中发布的交易如何对用户身份进行确认呢?只要知道对方的地址,就可以获取到对方的Output,如果仅仅靠一个转账对象就能够成功转出对方账户的资金,那么这不全乱套了么。所以对于一笔交易,除了需要构造交易必须的元素之外,还需要对交易进行签名,向区块链证明,是账户的所有者在进转出交易。 在NEO Core中的签名方法在neo/Cryptography/ECC/ECDsa.cs文件中定义,由于这部分属于密码学范畴,不属于我要分析的部分,这里就大概提一下:

源码位置:thinsdk-ts/thinneo/Helper.cs/Sign

 //计算公钥
 var PublicKey = ECPoint.multiply(ECCurve.secp256r1.G, privateKey);
 var pubkey = PublicKey.encodePoint(false).subarray(1, 64);
 //获取CryptoKey
 var key = new CryptoKey.ECDsaCryptoKey(PublicKey, privateKey);
 var ecdsa = new ECDsa(key);
 {
     //签名
     return new Uint8Array(ecdsa.sign(message,randomStr));
 }

真正签名的部分其实就是标准的ECDsa数字签名,返回值是一个长度为64的Uint8数组,前32字节是R,后32字节是S:

let arr = new Uint8Array(64);
Arrayhelper.copy(r.toUint8Array(false, 32), 0, arr, 0, 32);
Arrayhelper.copy(s.toUint8Array(false, 32), 0, arr, 32, 32);
return arr.buffer;

参数S和R都是ECDsa数字签名验证时非常重要的参数。

0x05 Witness

仅仅计算出签名是不够的,我们还需要将签名添加到交易中,这个过程就是添加见证人:

tran.AddWitness(signdata, pubkey, WalletHelper.wallet.address);

添加见证人的过程其实就是将上一步的签名信息和通过公钥获取到的验证信息push到见证人脚本中,删除了复杂验证过程的见证人添加过程如下:

源码位置:thinsdk-ts/thinneo/Transaction.cs

//增加个人账户见证人(就是用这个人的私钥对交易签个名,signdata传进来)
 public AddWitness(signdata: Uint8Array, pubkey: Uint8Array, addrs: string): void {
    var vscript = Helper.GetAddressCheckScriptFromPublicKey(pubkey);
    //iscript 对个人账户见证人他是一条pushbytes 指令
    var sb = new ScriptBuilder();
    sb.EmitPushBytes(signdata);
    var iscript = sb.ToArray();
    this.AddWitnessScript(vscript, iscript);
}

//增加智能合约见证人
public AddWitnessScript(vscript: Uint8Array, iscript: Uint8Array): void {
    var newwit = new Witness();
    newwit.VerificationScript = vscript;
    newwit.InvocationScript = iscript;
    //添加新见证人
    this.witnesses.push(newwit);

}

这里我还是有问题的,就是这个交易加入涉及到一个钱包下的多个账户,是不是应该有多个签名呢?理论上肯定是的,毕竟每个账户都拥有自己独立的私钥,所以我又去看了下GUI的转账代码. GUI获取交易并签名的入口是位于MainForm文件中的转账方法,调用的是Helper中的SignAndShowInformation,在这个SignAndShowInformation中,调用的是Wallet文件中的Sign方法,传入的是交易的上下文:

源码位置:neo/Wallets/Wallet.cs

public bool Sign(ContractParametersContext context)
{
    bool fSuccess = false;
    foreach(UInt160 scriptHash in context.ScriptHashes)
    {
        WalletAccount account = GetAccount(scriptHash);
        if (account ?.HasKey != true) continue;
        KeyPair key = account.GetKey();
        byte[] signature = context.Verifiable.Sign(key);
        fSuccess |= context.AddSignature(account.Contract, key.PublicKey, signature);
    }
    return fSuccess;
}

从源码中可以看出,NEO在对交易进行签名的时候会分别使用每一个参与交易的账户的私钥来进行签名。在签名完成后,会调用GetScripts方法来获取脚本,就是在这个方法中,交易添加了多个见证人:

public Witness[] GetScripts()
{
    if (!Completed) throw new InvalidOperationException();
    // 脚本哈希数量 == 见证人数量
    Witness[] scripts = new Witness[ScriptHashes.Count];
    for (int i = 0; i < ScriptHashes.Count; i++)
    {
        ContextItem item = ContextItems[ScriptHashes[i]];
        using(ScriptBuilder sb = new ScriptBuilder())
        {
            foreach(ContractParameter parameter in item.Parameters.Reverse())
            {
                sb.EmitPush(parameter);
            }
            //添加新的见证人
            scripts[i] = new Witness
            {
                InvocationScript = sb.ToArray(),
                    VerificationScript = item.Script ?? new byte[0]
             };
        }
    }
    return scripts;
}

所以如果你的GUI钱包往外转账金额大于单独一个账户的金额时,其实是多个账户共同完成一笔交易的。

下载连接:《Mastering BitCoin》:https://github.com/Liaojinghui/BlockChainBooks

转载自:https://my.oschina.net/u/2276921/blog/1644982

Discord:https://discord.io/neo
Telegram英文群:https://t.me/NEO_EN
Telegram中文群:https://t.me/NEO_Chinese
开发者交流QQ群:795681763

发表评论

Top