创建文章

We are looking for publications that demonstrate building dApps or smart contracts!
See the full list of Gitcoin bounties that are eligible for rewards.

Solution Thumbnail

如何用C#编写跨平台的Algorand钱包

简介

前言

本解决方案主要讨论如何使用C#编写一个跨平台、带GUI、非常便于操作的Algorand的钱包。但由于钱包所涉及的代码非常多,本文不可能一一介绍。但开发一个钱包所需要的关键技术,本文都进行详细介绍。

本解决方案中涉及到连接Algorand、GUI开发、如何安全的保存密码等问题,需要读者有一定的C#基础及相关的Algorand的知识。

开发环境及需求

开发系统环境:Windows 10

C# IDE:Visual Studio 2019

主要依赖项:使用dotnet-algorand-sdk(v 0.1.2.6)进行algorand连接;使用Avalonia进行跨平台GUI的编写;使用BouncyCastle进行密码相关的操作。

上面三个主要依赖项都是通过nuget安装的,请注意dotnet-algorand-sdk在nuget中名称为Algorand,而且不是最新版本。Avalonia也使用的为最新的稳定版本。

程序介绍

本解决方案实现一个Algorand钱包,具有连接algorand网络、展示交易列表、algo发送、接收、ASA的创建、发送、接收等功能。你可以在RileyGe/algo-wallet: A crossplat form algo asset wallet找钱包的完整代码,主要功能界面展示如下:

enter-password

链接 Algorand 网络

create-import-wallet

创建或导入钱包

new-wallet-step1

创建新钱包-助记词

new-wallet-step2

新建钱包-输入信息

information

展示交易列表

send

ALGO 转账

create-asset

创建 ASA 资产

Algorand连接及相关操作

这里只简单的描述一下dotnet-algorand-sdk的使用方法,更详细可以参照:《使用.net进行Algorand开发系列教程之开发环境搭建》、《使用.net进行Algorand开发系列教程之账号操作》、《使用.net进行Algorand开发系列教程之ASA的创建及管理》、《使用.net进行Algorand开发系列教程之ASA转账》及视频教程《使用.net进行Algorand开发系列教程之开发环境搭建及转账操作》、《使用.net进行Algorand开发系列教程之ASA相关操作》及英文教程《Build Algorand iOS, Android and UWP apps using C# .NET SDK and Xamarin》。

连接 Algorand

无论使用独立节点还是使用purestake服务,使用.net sdk连接Algorand网络是非常容易的。如果使用独立节点需要使用IP地址(REST endpoint’s IP address)和algod密钥(algod token),而使用purestake的服务就需要PureStake的API ADDRESS和API KEY。但在代码上两者没有任何不同。下面就使用一段代码连接Algorand网络及查询代币总供应量。

string ALGOD_API_ADDR = "ALGOD_API_ADDR";  //在此处添加ALGOD TESTNET API地址
string ALGOD_API_TOKEN = "ALGOD_API_TOKEN";    //在此处添加ALGOD API KEY
AlgodApi algodApiInstance = new AlgodApi(ALGOD_API_ADDR, ALGOD_API_TOKEN);

try
{
    Supply supply = algodApiInstance.GetSupply();
    Console.WriteLine("Total Algorand Supply: " + supply.TotalMoney);
    Console.WriteLine("Online Algorand Supply: " + supply.OnlineMoney);
}catch (ApiException e)
{
    Console.WriteLine("Exception when calling algod#getSupply: " + e.Message);
}

签名操作

我们为什么要进行签名操作呢?Vitalik Buterin曾经说区块链是一台永不停机的世界电脑。没有人是这台电脑的所有人(去中心化),每个人都可以使用这台电脑。但如果每个人都可以自由的使用这台电脑,那么很快这台电脑上很快就会充斥很多垃圾,有用的信息得不到执行。那么我们如何保证这台电脑正常运行呢?主要有两点:一、每个操作都需要明确是哪一个地址进行的(Transaction的发送者);二、每个操作都要付出一定的代价(gas)。

一般情况下是如何实现这些操作的呢?主要是通过签名。如果你对一个事件(Transaction)进行了签名,就证明你认可了这个事件,这个事件进行发送的时候有可能需要从你的账号里面扣除费用(gas)。所以说所有操作在发送到网络之前都需要进行一次或多次签名。

这里就最常用的签名方法和大家一起进行探讨。签名都是通过Account类的对象来进行的,如4.3节代码中的的scr对象。签名最常用的方法scr.SignTransaction方法。这个方法非常简单,只接收一个Transaction类的对象即可。但此方法几乎可以应对所有常用需求。有时你可能希望手动的改变交易时的费用,使交易能够更多快完成,这时你就需要使用src.SignTransactionWithFeePerByte方法,这个方法会在签名的同时更改网络费用(注意,费用是每个字节的费用,并不是说整个交易的费用)。

在.net sdk中签名操作是非常简单的,而且由于签名操作并不是单独完成的,所以这里就不单独进行代码示例,4.3节中转账操作中会有签名操作。

转账操作

无论是转账,还是后面其他的操作,一次交易的一般流程如下:

image-20210106164553579

转账操作和下一节所述的签名操作是账户最常用的操作。但实际上在转账操作中也涉及了对数据的签名,但关于签名的更多细节不会在此节中展开。注:此部分代码只展示了帐户及转账部分的代码,在转账开始前需要连接Algorand网络及获取TransactionParams。

// 向DEST_ADDR转账0.1algo
// 注意转账都是Micro Algos为单位的,1 Algo = 1e6 Micro Algos
// 两者之间的转换可以用Utils.AlgosToMicroalgos及Utils.MicroalgosToAlgos
ulong? amount = 100000;
ulong? lastRound = firstRound + 1000; // 1000 is the max tx window            
string SRC_ACCOUNT = "typical permit hurdle hat song detail cattle merge oxygen crowd arctic cargo smooth fly rice vacuum lounge yard frown predict west wife latin absent cup";
Account src = new Account(SRC_ACCOUNT);
Console.WriteLine("My account address is:" + src.Address.ToString());
string DEST_ADDR = "KV2XGKMXGYJ6PWYQA5374BYIQBL3ONRMSIARPCFCJEAMAHQEVYPB7PL3KU";
// Transaction中包含一次交易中几乎所有的信息
// 需要注意的是Transaction需要经过签名才能发送到区区块链
Transaction tx = new Transaction(src.Address, new Address(DEST_ADDR), amount, firstRound, lastRound, genesisID, genesisHash);
//sign the transaction before send it to the blockchain
SignedTransaction signedTx = src.SignTransactionWithFeePerByte(tx, (ulong)feePerByte);
Console.WriteLine("Signed transaction with txid: " + signedTx.transactionID);// send the transaction to the network
try
{
//交易信息编码
var encodedMsg = Algorand.Encoder.EncodeToMsgPack(signedTx);
//发送交易
    TransactionID id = algodApiInstance.RawTransaction(encodedMsg);
    Console.WriteLine("Successfully sent tx with id: " + id.TxId);
}catch (ApiException e)
{
    // This is generally expected, but should give us an informative error message.
    Console.WriteLine("Exception when calling algod#rawTransaction: " + e.Message);
}

这里有必要对如何创建并填充交易信息来进行一点说明。几乎所有的交易信息都可以直接使用Transaction类来进行创建,上文的代码也是直接用了Transaction类。

在.net SDK中,Utils类下面有若干个GetXXXXTransaction方法。这些方法内部其实也是引用了Transaction来完成操作的,只不过是提升了其使用的便捷性。

编码和发送交易的操作基本都是上面的套路,所以.net SDK为了便于用户使用,增加了一个Utils.SubmitTransaction方法,可以一步完成编码及发送工作。

ASA 相关操作

ASA的相关操作比较多,更具体的教程可以参照《使用.net进行Algorand开发系列教程之ASA的创建及管理》、《使用.net进行Algorand开发系列教程之ASA转账》及视频教程《使用.net进行Algorand开发系列教程之ASA相关操作》。这里只展示一段进行ASA转账操作的代码:

image-20210106164747990

    // ASA转账
    // 激活后account3就可以接收ASA了
    // 现在我们从acctout1向account3转账
    // First we update standard Transaction parameters
    // To account for changes in the state of the blockchain
    transParams = algodApiInstance.TransactionParams();
    // Next we set asset xfer specific parameters
    // We set the assetCloseTo to null so we do not close the asset out
    ulong assetAmount = 10;
    tx = Utils.GetTransferAssetTransaction(acct1.Address, acct3.Address, assetID, assetAmount, transParams, null, "transfer message");
    // The transaction must be signed by the sender account
    // We are reusing the signedTx variable from the first transaction in the example    
    signedTx = acct1.SignTransaction(tx);
    // send the transaction to the network and
    // wait for the transaction to be confirmed
    try
    {
        TransactionID id = Utils.SubmitTransaction(algodApiInstance, signedTx);
        Console.WriteLine("Transaction ID: " + id.TxId);
        Console.WriteLine(Utils.WaitTransactionToComplete(algodApiInstance, id.TxId));
        // We can now list the account information for acct3 
        // and see that it now has 5 of the new asset
        act = algodApiInstance.AccountInformation(acct3.Address.ToString());
        Console.WriteLine(act.GetHolding(assetID).Amount);
    }
    catch (Exception e)
    {
        //e.printStackTrace();
        Console.WriteLine(e.Message);
        return;
    }

界面

虽然微软官方已经发布了.net core这一个跨平台的代码解决方案,但目前微软官方是没有一个跨平台的GUI解决方案的,好在我们有Avalonia。Avalonia是一个跨平台的XAML框架,可以用于.net core和mono的开发。

Avalonia是基于XAML的,而微软的WPF也是基于XAML的,也就是说如果你有WPF的开发经验,那么你使用Avalonia也完全没有难度。大家可能会注意到,在完整钱包里界面设计使用的是.xaml文件,但在后面的教程里面界面设计文件的扩展名为.axaml。严格来说两者并没有太大区别。.axaml是在Avalonia 0.9.11之后引入的,主要是为了解决Visual Studio的显示问题。Visual Studio本身就支持.xaml文件,所以Visual Studio中有很多关于.xaml文件的语法检查功能,而且这些功能无法被改写。所以Avalonia引入了新的文件扩展名.axaml来适应Avalonia的语法。

下面就以“图一:连接Algorand网络”的界面为例,简单的说明一下如何使用Avalonia进行界面设计。

创建 Avalonia 项目

首先,Avalonia提供了一个Visual Studio插件。此插件提供了项目模板及一个Designer。使用项目模板可以快速创建Avalonia项目,使用Designer可以让我们以可视化的设计程序界面。

image-20210106164919656

新建名为“algo_wallet_tutorial”的“Avalonia Application”项目。由于选择了Avalonia模板,所以新建的项目会自动的安装Avalonia的依赖项,非常方便。

大家也许注意到了,Avalonia提供了两个项目模板,一个是我们使用的“Avalonia Application”项目,还有一个是“Avalonia MVVM Application”项目。前者是一个适应于使用code-behind模式进行开发的项目,后者是适应于MVVM模式开发的项目。

Code-behind模式与MVVM模型

即使大家可能并不熟悉code-behind和MVVM这两个名词,但大家或多或少都使用过这两种模式。下面对这两种模式进行简单的介绍。

code-behind模式就是将AXAML(Avalonia定制的XAML文件)文件与相应的类(一般为axaml.cs文件)一一对应。AXAML文件负责界面,类文件负责逻辑处理。此种方法入门十分简单,应对简单的程序非常得心应手。但随着程序复杂度的提高,会变的越来越难以修改与更新。由于本文介绍的界面与逻辑比较简单,所以使用code-behind模式进行开发。

MVVM就是View-Model-VeiwModel模式。其中View层用于显示,Model层是数据模型。而ViewModel充当了一个UI适配器的角色,也就是说View中每个UI元素都应该在ViewModel找到与之对应的属性。MVVM将界面显示与数据模型进行了分离,这样就可以很好的应对复杂的项目,但对于简单项目有时会显得比较臃肿。Avalonia也完美支持MVVM模式,更具体可以参照此教程:http://www.avaloniaui.net/docs/quickstart/mvvm

image-20210106165147794

界面设计

Avalonia拥有数量众多的控件(http://www.avaloniaui.net/docs/controls/)及布局(http://www.avaloniaui.net/docs/layout/),我们可以使用这些控件和布局来进行界面设计。下面的代码是类似于“图一:连接Algorand网络”的界面设计:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="algo_wallet_tutorial.MainWindow"
        Title="algo_wallet_tutorial">
  <Window.Styles>
    <Style Selector="TextBlock.h1">
      <Setter Property="FontSize" Value="24"/>
      <Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="HorizontalAlignment" Value="Center"/>
    </Style>
    <Style Selector="Button.btn">
      <Setter Property="Margin" Value="50 0"/>
      <Setter Property="Width" Value="150"/>
    </Style>
  </Window.Styles>
  <StackPanel Name="sp_enterPassword" Orientation="Vertical"
              HorizontalAlignment="Center">
    <TextBlock Classes="h1" Margin="0 20 0 0">Enter The Password</TextBlock>
    <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0 40 0 0">
      <ComboBox Width="100" Name="cb_accountList" SelectedIndex="0">
      </ComboBox>
      <TextBox Width="400" Name="tb_enterPassword" PasswordChar="*" FontWeight="16"></TextBox>
    </StackPanel>
    <StackPanel Orientation="Horizontal" Margin="0 40 0 0">
      <Button Classes="btn">Close</Button>
      <Button Classes="btn">OK</Button>
    </StackPanel>
  </StackPanel>
</Window>

如果大家使用过WPF,那么你可能对上述代码非常熟悉。如果你之前做过html或Android的开发,那么这种界面设计方法也并不会让你迷惑。但如果你之前只做过winform等拖拽形式的界面开发,那么你可能需要一点时间来适应这种设计方式。无论之前你做过何种界面设计,那么下一步都是相应的控件添加相应的响应事件。

控件的响应事件

Avalonia中的事件监听,最常用的有两种方法:

第一种是直接在AXMAL代码中给相应的Button添加相应的属性,如将<Button Classes="btn" Name="btn_enterpsd_ok">OK</Button>改为<Button Classes="btn" Name="btn_enterpsd_ok" Click="BtnEnterpsdClicked">OK</Button>然后在AXAML相应的 axaml.cs 文件中添加 BtnEnterpsdClicked 函数。本文并不推荐此方法,这是由于这并不利于逻辑与界面的分离。

第二种方法是通过代码添加事件监听。此方法可以分为三步:查找控件、添加事件监听及事件处理。有关事件监听的所有代码都在axaml.cs中实现,便于界面与逻辑的分享。具体实现方法如下:

using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace algo_wallet_tutorial
{
    public class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
#if DEBUG
            this.AttachDevTools();
#endif
            this.FindControl<Button>("btn_enterpsd_ok").Click += BtnEnterpsdClicked;
        }

        private void InitializeComponent()
        {
            AvaloniaXamlLoader.Load(this);            
        }

        private void BtnEnterpsdClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
        {
            var btn = sender as Button;
            btn.Content = "Clicked";
        }
    }
}

界面设计总结

虽然现阶段使用Avalonia进行界面设计还不是主流,但Avalonia与WPF并没有什么区别,所以如果你有WPF的开发经验,那么入门的门槛很低。而且Avalonia提供了更强的跨平台的能力。

另一方面Avalonia已经得到了.Net Foundation的支持,我想Avalonia直接进驻Visual Studio,逐步替代WPF,或者直接将Avalonia与WPF合并,使WPF直接具备跨平台的能力只是时间问题。

私钥的本地保存

钱包与SDK、命令行工具等最大的不同就在于钱包需要本地存储秘钥,使程序能够读取秘钥而进行更多的操作。但这就给秘钥的安全提出了挑战。所以,这一部分即使说是钱包中最重要的部分也不为过。我们对本地私钥存储提出两点要求:

一、本地不能直接存储私钥,而是存储私钥的某种加密后的内容。

二、加密足够安全,不能被暴力破解。

三、只有持有密码的人才能取得私钥。

四、私钥文件可以(不可以)在软件之间迁移。

为了实现以上目的,通过与Algorand Foundation讨论,我们一致认为用如下方式加密比较安全有效:

image-20210106165827736

私钥存储过程

image-20210106165853226

私钥解密过程

为了更直观的演示加密解密过程,我创建了几段代码演示。注意:以下代码只是流程演示作用,不保证可以运行。大家也可以到https://gist.github.com/RileyGe/55f588e0f29faf5465204f12bd450a0a上查看本段代码。

CryptoUtils.cs文件:

using System;
using System.Text;
using System.Collections.Generic;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;


namespace privatkey_scrypt
{
    public class CryptoUtils
    {
        private const int KEY_BIT_SIZE = 256;
        private const int MAC_BIT_SIZE = 128;
        public static byte[] DecryptAesGcm(byte[] key, byte[] nonce, byte[] cipherText, byte[] tag)
        {
            List<byte> msgList = new List<byte>(cipherText);
            msgList.AddRange(tag);
            byte[] message = msgList.ToArray();
            if (key == null || key.Length != KEY_BIT_SIZE / 8)
                throw new ArgumentException(String.Format("Key needs to be {0} bit!", KEY_BIT_SIZE), "key");
            if (message == null || message.Length == 0)
                throw new ArgumentException("Message required!", "message");

            var cipher = new GcmBlockCipher(new AesEngine());
            var parameters = new AeadParameters(new KeyParameter(key), MAC_BIT_SIZE, nonce);
            cipher.Init(false, parameters);
            var plainText = new byte[cipher.GetOutputSize(message.Length)];
            try
            {
                var len = cipher.ProcessBytes(message, 0, message.Length, plainText, 0);
                cipher.DoFinal(plainText, len);
            }
            catch (InvalidCipherTextException)
            {
                return null;
            }
            return plainText;
        }
        public static byte[] EncryptAesGcm(byte[] key, byte[] nonce, byte[] plaintext)
        {
            if (key == null || key.Length != KEY_BIT_SIZE / 8)
                throw new ArgumentException(String.Format("Key needs to be {0} bit!", KEY_BIT_SIZE), "key");
            var cipher = new GcmBlockCipher(new AesEngine());
            var parameters = new AeadParameters(new KeyParameter(key), MAC_BIT_SIZE, nonce);
            cipher.Init(true, parameters);
            var cipherText = new byte[cipher.GetOutputSize(plaintext.Length)];
            var len = cipher.ProcessBytes(plaintext, 0, plaintext.Length, cipherText, 0);
            cipher.DoFinal(cipherText, len);
            return cipherText;
        }
        public static byte[] GetCipherTextFromAesGcmResult(byte[] result)
        {
            if (result.Length == 16 + 32)
                return result.AsSpan().Slice(0, 32).ToArray();
            else
                return null;
        }
        public static byte[] GetTagFromAesGcmResult(byte[] result)
        {
            if (result.Length == 16 + 32)
                return result.AsSpan().Slice(32, 16).ToArray();
            else
                return null;
        }
        /// <summary>
        /// Generate a random 16 bits salt
        /// </summary>
        /// <returns>16 bits salt</returns>
        public static byte[] GenerateRandomSalt()
        {
            var salt = new byte[16];
            new SecureRandom().NextBytes(salt);
            return salt;
        }
        /// <summary>
        /// Use Scrypt to generate 48 bytes hash.
        /// Scrypt is used instead of Bscrypt because Bscrypt cannot work when importing the wallet
        /// </summary>
        /// <param name="salt">salt</param>
        /// <param name="pwd">password</param>
        /// <returns>the generated 48 bytes hash</returns>
        public static byte[] GenerateHash(byte[] salt, string pwd)
        {
            if (salt.Length != 16)
                return null;
            var password = Encoding.UTF8.GetBytes(pwd);
            const int SCryptN = 262144;
            return SCrypt.Generate(password, salt, SCryptN, 8, 1, 48);
        }
        /// <summary>
        /// Use the first 16 bytes to check the entered password is correct
        /// </summary>
        /// <param name="key">a 48 bytes, usually generated by Scrypt</param>
        /// <returns>the first 16 bytes</returns>
        public static byte[] GetCheckSalt(byte[] key)
        {
            if (key.Length != 48)
                return null;
            var key_list = new List<byte>(key);
            key_list.RemoveRange(16, 32);
            return key_list.ToArray();
        }
        /// <summary>
        /// Use the last 32 bytes as the Master-Key of algorand account
        /// </summary>
        /// <param name="key">a 48 bytes, usually generated by Scrypt</param>
        /// <returns>the last 32 bytes</returns>
        public static byte[] GetMasterKey(byte[] key)
        {
            if (key.Length != 48)
                return null;
            var key_list = new List<byte>(key);
            key_list.RemoveRange(0, 16);
            return key_list.ToArray();
        }
    }
}

Program.cs 文件:

using System;
using System.Linq;
using System.Text;

namespace privatkey_scrypt
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
        /// <summary>
        /// 加密过程
        /// </summary>
        /// <param name="accountPassword">密码</param>
        /// <param name="masterKey">私钥:32位</param>
        static void scrypt(string accountPassword, byte[] masterKey)
        {
            var salt = CryptoUtils.GenerateRandomSalt(); //生成16位的随机salt,需要存储于本地
            var scryptedKey = CryptoUtils.GenerateHash(salt, accountPassword); //生成48位二进制
            var checkSalt = CryptoUtils.GetCheckSalt(scryptedKey); //前16位用于存储本地,后续用于较验密码
            //GetMasterKey只是获取了48位二进制的后32位,此32位并不是MasterKey,是用于AESGCM加密的Key
            //由于历史原因起名为GetMasterKey,为了与Algo-Wallet一致此处也用此名字
            var aesgcmKey = CryptoUtils.GetMasterKey(scryptedKey);
            //如果需要秘钥文件可以在不同的程序间迁移,则用固定值
            //如果需要秘钥文件在不同的程序间不可迁移,请使用
            //Org.BouncyCastle.Security.SecureRandom类生成随机nonce
            var nonce = Encoding.UTF8.GetBytes("algo--wallet");
            // aesgcmCipherBytes为48位二进制,存储本地
            var aesgcmCipherBytes = CryptoUtils.EncryptAesGcm(aesgcmKey, nonce, masterKey);
        }
        /// <summary>
        /// 解密过程
        /// </summary>
        /// <param name="accountPassword">密码</param>
        /// <param name="salt">存储于本地的16位salt</param>
        /// <param name="checkSalt">存储于本地的16位二进制</param>
        /// <param name="aesgcmCipherBytes">存储于本地的48位二进制</param>
        static void descrypt(string accountPassword, byte[] salt, byte[] checkSalt, byte[] aesgcmCipherBytes)
        {
            var scryptedKey = CryptoUtils.GenerateHash(salt, accountPassword); //生成48位二进制
            var calcedCheckSalt = CryptoUtils.GetCheckSalt(scryptedKey); //前16位用于存储本地,后续用于较验密码
            if(!Enumerable.SequenceEqual(checkSalt, calcedCheckSalt)) return; //密码错误
            var nonce = Encoding.UTF8.GetBytes("algo--wallet");
            //解密得到masterKey
            var masterKey = CryptoUtils.DecryptAesGcm(CryptoUtils.GetMasterKey(scryptedKey), nonce, 
                CryptoUtils.GetCipherTextFromAesGcmResult(aesgcmCipherBytes), 
                CryptoUtils.GetTagFromAesGcmResult(aesgcmCipherBytes));
        }
    }
}

总结

本文洋洋洒洒的写了挺长的,从dotnet-algorand-sdk的使用,到界面的简单设计再到私钥的本地存储的加密方式,可以说涵盖写一个钱包的方方面面。但实际上一个钱包的写作远不止这些,有更多的细节需要注意。如果大家有兴趣可以到Algo-Wallet项目中,仔细的研究一下钱包的更多技术细节。最后,希望本文能够对大家写作自己的区块链钱包有一定的引导作用。