This is the old Algorand Developer Portal. Please head over to dev.algorand.co to explore our newly rebuilt documentation site. Please excuse us as we continue to transition content to the new portal

创建文章

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项目中,仔细的研究一下钱包的更多技术细节。最后,希望本文能够对大家写作自己的区块链钱包有一定的引导作用。