以太坊钱包开发系列

1 - 创建钱包账号

以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签名交易、发送Token(代币),这是第一篇,主要介绍钱包将实现哪些功能及怎么创建钱包账号,本钱包是基于ethers.js 进行开发。

去中心化网页钱包

先明确一下定义,什么是去中心化钱包,账号秘钥的管理,交易的签名,都是在客户端完成, 即私钥相关的信息都是在用户手中,钱包的开发者接触不到私钥信息。

对应的中心化钱包则是私钥由中心服务器托管,如交易所的钱包就是这种。

网页钱包,或者叫web钱包,是指钱包以网页的形式展现,去中心化网页钱包则交易的签名等操作是在浏览器里完成。
其他形式的钱包,如Android钱包或iOS钱包其开发思路和web钱包一样,因此文本对开发其他平台的钱包也有参考意义,不过本系列文章主要侧重在钱包功能的实现,并未过多考虑用户体验。

钱包功能

一个钱包通常主要包含的功能有:

  • 账号管理(主要是私钥的管理):创建账号、账号导入导出
  • 账号信息展示:如以太币余额、Token(代币)余额。
  • 转账功能:发送以太币及发送Token(代币)

这些功能将基于 ethers.js 进行开发, ethers.js 和web3.js 一样,也是一套和以太坊区块链进行交互的库,不仅如此,ethers.js 还对BIP 39等相关的提案进行了实现,可以在这个链接阅读其文档。

这些功能主要表现为钱包的两个界面,一个界面是:账号管理,一个界面是进行账号信息展示及转账。下面逐个进行介绍

创建钱包账号

读过上一篇文章理解开发HD 钱包涉及的 BIP32、BIP44、BIP39的同学,会知道创建账号,可以有两种方式:

  • 直接生成32个字节的数当成私钥
  • 通过助记词进行确定性推导出私钥

使用随机数作为私钥创建钱包账号

即方式一,可以使用ethers.utils.randomBytes生成一个随机数,然后使用这个随机数来创建钱包,如代码:

var privateKey = ethers.utils.randomBytes(32);
var wallet = new ethers.Wallet(privateKey);
console.log("账号地址: " + wallet.address);

上面代码的 wallet 是 ethers 中的一个钱包对象,它除了有代码中出现的.address 属性之外,还有如 获取余额、发送交易等方法,在后面的文章会进行介绍。

注意ethers.utils.randomBytes 生成的是一个字节数组,如果想用十六进制数显示出来表示,需要转化为BigNumber代码如下:

let keyNumber = ethers.utils.bigNumberify(privateKey);
console.log(randomNumber._hex);

现在我们结合界面,完整的实现创建账号,其效果图如下,加载私钥时创建账号。

界面代码(HTML)代码如下(主要是在表格中定义个一个输入框及一个按钮):

                
私钥:
加载私钥

对应的逻辑代码(JavaScript)如下:

// 使用JQuery获取两个UI标签
    var inputPrivatekey = $('#select-privatekey');
    var submit = $('#select-submit-privatekey');

// 生成一个默认的私钥
    let randomNumber = ethers.utils.bigNumberify(ethers.utils.randomBytes(32));
    inputPrivatekey.val(randomNumber._hex);

// 点击“加载私钥”时, 创建对应的钱包
    submit.click(function() {
        var privateKey = inputPrivatekey.val();
        if (privateKey.substring(0, 2) !== '0x') { privateKey = '0x' + privateKey; }
       var wallet = new ethers.Wallet(privateKey));

    });

如果用户提供一个已有账号的私钥,则会导入其原有账号。

通过助记词方式创建钱包账号

这是目前主流常见钱包的方式,关于助记词推导过程请阅读理解开发HD 钱包涉及的 BIP32、BIP44、BIP39。

我们需要先生成一个随机数,然后用随机数生成助记词,随后用助记词创建钱包账号,设计到的API有:


var rand = ethers.utils.randomBytes(16);

// 生成助记词
var mnemonic = ethers.utils.HDNode.entropyToMnemonic(rand);

var path = "m/44'/60'/0'/0/0";

// 通过助记词创建钱包
ethers.Wallet.fromMnemonic(mnemonic, path);

现在我们结合界面来实现一下通过助记词方式创建钱包账号,其效果图如下:

界面代码(HTML)代码如下(主要是在表格中定义个两个输入框及一个按钮):

    
助记词:
Path:
推倒

对应的逻辑代码(JavaScript)如下:

    var inputPhrase = $('#select-mnemonic-phrase');
    var inputPath = $('#select-mnemonic-path');
    var submit = $('#select-submit-mnemonic');

// 生成助记词
    var mnemonic = ethers.utils.HDNode.entropyToMnemonic(ethers.utils.randomBytes(16));
    inputPhrase.val(mnemonic);

    submit.click(function() {
    // 检查助记词是否有效。
        if (!ethers.utils.HDNode.isValidMnemonic(inputPhrase.val())) {
            return;
        }

// 通过助记词创建钱包对象
       var wallet = ethers.Wallet.fromMnemonic(inputPhrase.val(), inputPath.val());
    });

同样用户可以提供一个其保存的助记词来导入其钱包,有一些遗憾的是,ethers.js 暂时不支持通过添加密码作为Salt来保护种子(也可能是我没有找到,如果知道的同学,希望反馈下),如果需要此功能可以引入bip39 和 ethereumjs-wallet 库来实现,代码可参考理解开发HD 钱包涉及的 BIP32、BIP44、BIP39。

小结

其实 ethers 还提供了一个更简单的方法来创建钱包:

   // 直接创建一个随机钱包
   ethers.Wallet.createRandom();

完整源码请订阅深入浅出区块链技术小专栏查看, 哈哈,是不是有一点鸡贼,创作不易呀。
戳链接收看详细的视频课程讲解。

参考文档:
ethers.js

 

2 - 账号Keystore文件导入导出

以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签名交易、发送Token(代币),这是第二篇,主要介绍钱包账号导出与导入,将对Keystore文件的生成的原理进行介绍。

如何导入Geth创建的账号?

在上一篇文章,介绍了如何使用私钥及助记词来创建账号,如果是使用已有的私钥及助记词,这其实也是账号导入的过程。

有一些同学会问,我的账号是Geth生成的,如何导入到钱包呢?使用Geth的同学,应该知道Geth在创建账号时会生成一个对应keystore JSON文件,Keystore文件存储加密后的私钥信息,因此我们需要做的就是导入这个Keystore文件,这个文件通常在同步区块数据的目录下的keystore文件夹(如: ~/.ethereum/keystore)里。

尽管在ethers.js 中,简单的使用一个函数就可以完成keystore文件的导入,不过理解Keystore 文件的作用及原理还是非常有必要的,当然如果你是在没有兴趣,可以直接跳到本文最后一节:使用ethers.js 实现账号导出导入。

详细解读 Keystore 文件

为什么需要 Keystore 文件

通过这篇文章理解开发HD 钱包涉及的 BIP32、BIP44、BIP39,私钥其实就代表了一个账号,最简单的保管账号的方式就是直接把私钥保存起来,如果私钥文件被人盗取,我们的数字资产将洗劫一空。

Keystore 文件就是一种以加密的方式存储密钥的文件,这样的发起交易的时候,先从Keystore 文件是使用密码解密出私钥,然后进行签名交易。这样做之后就会安全的多,因为只有黑客同时盗取 keystore 文件和密码才能盗取我们的数字资产。

Keystore 文件如何生成的

以太坊是使用对称加密算法来加密私钥生成Keystore文件,因此对称加密秘钥(注意它其实也是发起交易时需要的解密秘钥)的选择就非常关键,这个秘钥是使用KDF算法推导派生而出。因此在完整介绍Keystore 文件如何生成前,有必要先介绍一下KDF。

使用 KDF 生成秘钥

密码学KDF(key derivation functions),其作用是通过一个密码派生出一个或多个秘钥,即从 password 生成加密用的 key。

其实在理解开发HD 钱包涉及的 BIP32、BIP44、BIP39中介绍助记词推导出种子的PBKDF2算法就是一种KDF函数,其原理是加盐以及增加哈希迭代次数。

而在Keystore中,是用的是Scrypt算法,用一个公式来表示的话,派生的Key生成方程为:

DK = Scrypt(salt, dk_len, n, r, p)

其中的 salt 是一段随机的盐,dk_len 是输出的哈希值的长度。n 是 CPU/Memory 开销值,越高的开销值,计算就越困难。r 表示块大小,p 表示并行度。

Litecoin 就使用 scrypt 作为它的 POW 算法

实际使用中,还会加上一个密码进行计算,用一张图来表示这个过程就是:

对私钥进行对称加密

上面已经用KDF算法生成了一个秘钥,这个秘钥就是接着进行对称加密的秘钥,这里使用的对称加密算法是 aes-128-ctr,aes-128-ctr 加密算法还需要用到一个参数初始化向量 iv。

Keystore文件

好了,我们现在结合具体 Keystore文件的内容,就很容易理解了Keystore 文件怎么产生的了。

{  
   "address":"856e604698f79cef417aab...",
   "crypto":{  
      "cipher":"aes-128-ctr",
      "ciphertext":"13a3ad2135bef1ff228e399dfc8d7757eb4bb1a81d1b31....",
      "cipherparams":{  
         "iv":"92e7468e8625653f85322fb3c..."
      },
      "kdf":"scrypt",
      "kdfparams":{  
         "dklen":32,
         "n":262144,
         "p":1,
         "r":8,
         "salt":"3ca198ce53513ce01bd651aee54b16b6a...."
      },
      "mac":"10423d837830594c18a91097d09b7f2316..."
   },
   "id":"5346bac5-0a6f-4ac6-baba-e2f3ad464f3f",
   "version":3
}

来解读一下各个字段:

  • address: 账号地址
  • version: Keystore文件的版本,目前为第3版,也称为V3 KeyStore。
  • id : uuid
  • crypto: 加密推倒的相关配置.
    • cipher 是用于加密以太坊私钥的对称加密算法。用的是 aes-128-ctr 。
    • cipherparams 是 aes-128-ctr 加密算法需要的参数。在这里,用到的唯一的参数 iv。
    • ciphertext 是加密算法输出的密文,也是将来解密时的需要的输入。
    • kdf: 指定使用哪一个算法,这里使用的是 scrypt。
    • kdfparams: scrypt函数需要的参数
    • mac: 用来校验密码的正确性, mac= sha3(DK[16:32], ciphertext) 下面一个小节单独分析。

我们来完整梳理一下 Keystore 文件的产生:
1. 使用scrypt函数 (根据密码 和 相应的参数) 生成秘钥
2. 使用上一步生成的秘钥 + 账号私钥 + 参数 进行对称加密。
3. 把相关的参数 和 输出的密文 保存为以上格式的 JSON 文件

如何确保密码是对的?

当我们在使用Keystore文件来还原私钥时,依然是使用kdf生成一个秘钥,然后用秘钥对ciphertext进行解密,其过程如下:

此时细心的同学会发现,无论使用说明密码,来进行这个操作,都会生成一个私钥,但是最终计算的以太坊私钥到底是不是正确的,却不得而知。

这就是 keystore 文件中 mac 值的作用。mac 值是 kdf输出 和 ciphertext 密文进行SHA3-256运算的结果,显然密码不同,计算的mac 值也不同,因此可以用来检验密码的正确性。检验过程用图表示如下:

现在我们以解密的角度完整的梳理下流程,就可以得到以下图:

用ethers.js 实现账号导出导入

ethers.js 直接提供了加载keystore JSON来创建钱包对象以及加密生成keystore文件的方法,方法如下:

// 导入keystore Json
    ethers.Wallet.fromEncryptedJson(json, password, [progressCallback]).then(function(wallet) {
       // wallet
    });

    // 使用钱包对象 导出keystore Json
    wallet.encrypt(pwd, [progressCallback].then(function(json) {
        // 保存json
    });

现在结合界面来完整的实现账号导出及导入,先看看导出,UI图如下:

HTML 代码如下:

    

KeyStore 导出:

密码:
导出

上面主要定义了一个密码输入框和一个导出按钮,点击“导出”后,处理逻辑代码如下:

// "导出" 按钮,执行exportKeystore函数
  $('#save-keystore').click(exportKeystore);

  exportKeystore: function() {
    // 获取密码
    var pwd = $('#save-keystore-file-pwd');

    // wallet 是上一篇文章中生成的钱包对象
    wallet.encrypt(pwd.val()).then(function(json) {
      var blob = new Blob([json], {type: "text/plain;charset=utf-8"});

      // 使用了FileSaver.js 进行文件保存
      saveAs(blob, "keystore.json");

    });
  }

FileSaver.js 是可以用来在页面保存文件的一个库。

再来看看导入keystore 文件, UI图如下:

 

加载账号Keystore文件

Keystore:
把Json文件拖动到这里
密码:
解密

上面主要定义了一个文件输入框、一个密码输入框及一个“解密“按钮,因此处理逻辑包含两部分,一是读取文件,二是解析加载账号,关键代码如下:

 // 使用FileReader读取文件,

var fileReader = new FileReader();
  fileReader.onload = function(e) {
    var json = e.target.result;

    // 从加载
    ethers.Wallet.fromEncryptedJson(json, password).then(function(wallet) {

    }, function(error) {

    });

  };
fileReader.readAsText(inputFile.files[0]);

哈哈哈,有到了推广时间了,完整源码请订阅深入浅出区块链技术小专栏查看,赶紧订阅吧,走过路过,不容错过。

参考文档

what-is-an-ethereum-keystore-file

 

3 - 展示钱包信息及发起签名交易

以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签名交易、发送Token(代币),这是第三篇介绍使用ethers.js的钱包对象获取相关信息及发起你离线交易。

使用 Provider 连接以太坊网络

我们前面两篇文章介绍创建(或导入)钱包账号的过程都是是离线的,即不需要依赖以太坊网络即可创建钱包账号,但如果想获取钱包账号的相关信息,比如余额、交易记录,发起交易的话,就需要让钱包连上以太坊的网络。

不管是在 Web3 中,还是Ethers.js 都是使用 Provider 来进行网络连接的,Ethers.js 提供了集成多种 Provider 的方式:

  • Web3Provider: 使用一个已有的web3 兼容的Provider,如有MetaMask 或 Mist提供。

  • EtherscanProvider 及 InfuraProvider: 如果没有自己的节点,可以使用Etherscan 及 Infura 的Provider,他们都是以太坊的基础设施服务提供商,Ethers.js 还提供了一种更简单的方式:使用一个默认的provider, 他会自动帮我们连接Etherscan 及 Infura。

    let defaultProvider = ethers.getDefaultProvider('ropsten');

    连接Provider, 通常有一个参数network网络名称,取值有: homesteadrinkebyropstenkovan, 关于Provider的更多用法,可以参考Ethers.js Provider。

  • JsonRpcProvider 及 IpcProvider: 如果有自己的节点可以使用,可以连接主网,测试网络,私有网络或Ganache,这也是本系列文章使用的方式。

使用钱包连接Provider的方法如下:

// 连接本地的geth 节点,8545是geth 的端口
var provider = new ethers.providers.JsonRpcProvider("http://127.0.0.1:8545");

// wallet 为前两篇文章中生成的钱包对象, activeWallet就是后面可以用来请求余额发送交易的对象
var activeWallet = wallet.connect(App.provider);

启动geth的需要注意一下,需要使用 --rpc --rpccorsdomain 开启 RPC通信及跨域,

展示钱包详情:查询余额及Nonce

连接到以太坊网络之后,就可以向网络请求余额以及获取账号交易数量,使用一下API:

activeWallet.getBalance().then(function(balance) {
});

activeWallet.getTransactionCount().then(function(transactionCount) {
});

activeWallet就是后面可以用来请求发送交易的对象

钱包详情:

地址:
余额:
Nonce:
刷新

js处理的逻辑就是获取信息之后,填充相应的控件,代码如下:

var inputBalance = $('#wallet-balance');
var inputTransactionCount = $('#wallet-transaction-count');

$("#wallet-submit-refresh").click(function() {

// 获取余额时, 包含当前正在打包的区块
   activeWallet.getBalance('pending').then(function(balance) {
          // 单位转换 wei -> ether
          inputBalance.val(ethers.utils.formatEther(balance, { commify: true }));
      }, function(error) {
      });

   activeWallet.getTransactionCount('pending').then(function(transactionCount) {
          inputTransactionCount.val(transactionCount);
      }, function(error) {
      });
});

// 模拟一次点击获取数据
$("#wallet-submit-refresh").click();

发送签名交易

之前我们有一篇文章:如何使用Web3.js API 在页面中进行转账介绍过发起交易,不过当时的签名是利用MetaMask来完成的,现在我们要完成一个钱包,必须要发送一个签名交易,签名交易也称为离线交易(因为这个过程可以离线进行:在离线状态下对交易进行签名,然后把签名后的交易进行广播)。

尽管 Ethers.js 提供了非常简洁的API来发送签名交易,但是探究下简洁API背后的细节依然会对我们有帮助,这个过程大致可分为三步:

  1. 构造交易
  2. 交易签名
  3. 发送(广播)交易

构造交易

先来看看一个交易长什么样子:

const txParams = {
  nonce: '0x00',
  gasPrice: '0x09184e72a000',
  gasLimit: '0x2710',
  to: '0x0000000000000000000000000000000000000000',
  value: '0x00',
  data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057',
  // EIP 155 chainId - mainnet: 1, ropsten: 3
  chainId: 3
}

发起交易的时候,就是需要填充每一个字段,构建这样一个交易结构。
to 和 value: 很好理解,就是用户要转账的目标及金额。
data: 是交易时附加的消息,如果是对合约地址发起交易,这会转化为对合约函数的执行,可参考:如何理解以太坊ABI
nonce: 交易序列号
chainId: 链id,用来去区分不同的链(分叉链)id可在EIP-55查询。

nonce 和 chainId 有一个重要的作用就是防止重放攻击,如果没有nonce的活,收款人可能把这笔签名过的交易再次进行广播,没有chainId的话,以太坊上的交易可以拿到以太经典上再次进行广播。

gasPricegasLimit: Gas是以太坊的工作计费机制,是由交易发起者给矿工打包的费用。上面几个参数的设置比较固定,Gas的设置(尤其是gasPrice)则灵活的多。

gasLimit 表示预计的指令和存储空间的工作量,如果工作量没有用完,会退回交易发起者,如果不够会发生out-of-gas 错误。
一个普通转账的交易,工作量是固定的,gasLimit为21000,合约执行gasLimit则是变化的,也许有一些人会认为直接设置为高一点,反正会退回,但如果合约执行出错,就会吃掉所有的gas。幸运的是web3 和 ethers.js 都提供了测算Gas Limit的方法,下一遍发送代币

gasPrice是交易发起者是愿意为工作量支付的单位费用,矿工在选择交易的时候,是按照gasPrice进行排序,先服务高出价者,因此如果出价过低会导致交易迟迟不能打包确认,出价过高对发起者又比较亏。

web3 和 ethers.js 提供一个方法 getGasPrice() 用来获取最近几个历史区块gas price的中位数,也有一些第三方提供预测gas price的接口,如:gasPriceOracle 、 ethgasAPI、 etherscan gastracker,这些服务通常还会参考当前交易池内交易数量及价格,可参考性更强,

常规的一个做法是利用这些接口给用户一个参考值,然后用户可以根据参考值进行微调。

交易签名

在构建交易之后,就是用私钥对其签名,代码如下:

const tx = new EthereumTx(txParams)
tx.sign(privateKey)
const serializedTx = tx.serialize()

代码参考ethereumjs-tx

发送(广播)交易

然后就是发送(广播)交易,代码如下:

web3.eth.sendRawTransaction(serializedTx, function (err, transactionHash) {
    console.log(err);
    console.log(transactionHash);
});

通过这三步就完成了发送签名交易的过程,ethers.js 里提供了一个简洁的接口,来完成所有这三步操作(强调一下,签名已经在接口里帮我们完成了),接口如下:

 activeWallet.sendTransaction({
            to: targetAddress,
            value: amountWei,
            gasPrice: activeWallet.provider.getGasPrice(),
            gasLimit: 21000,
        }).then(function(tx) {
        });

用ethers.js 实现发送交易

先来看看发送交易的UI界面:

以太转账:

发送至:
金额:
发送

上面主要定义了两个文本输入框及一个“发送“按钮,在点击发送时运行一下(关键)代码:

    var inputTargetAddress = $('#wallet-send-target-address');
    var inputAmount = $('#wallet-send-amount');
    var submit = $('#wallet-submit-send');

    submit.click(function() {
    // 得到一个checksum 地址
        var targetAddress = ethers.utils.getAddress(inputTargetAddress.val());
    // ether -> wei
        var amountWei = ethers.utils.parseEther(inputAmount.val());
        activeWallet.sendTransaction({
            to: targetAddress,
            value: amountWei,
            // gasPrice: activeWallet.provider.getGasPrice(),  (可用默认值)
            // gasLimit: 21000,
        }).then(function(tx) {
            console.log(tx);
        });
    })

哈哈哈~, 干活介绍到这里,现在夹带一点私货,有到了推广时间了,完整源码请订阅深入浅出区块链技术小专栏查看,赶紧订阅吧,走过路过,不容错过。

参考文档

  1. ethereum-tx
  2. EIP-55
  3. Ethers.js

 

你可能感兴趣的:(以太坊)