使用ether.js开发以太坊web3钱包

使用ether.js开发以太坊web3钱包

本篇教程不会过多的讲述关于web3、以太坊以及钱包的概念进行讲解,更多的是针对开发方面的讲解,并且默认你已经有了一定的web3和react基础,即使没有react基础有js基础也是可以的

项目准备

我们使用到的基础框架为umi 一种基于react+ts的框架
官网地址

https://umijs.org/

安装ether.js

npm install --save ethers

引入ether.js:以下是三种方法

es3:
var ethers = require(‘ethers’);
es5/es6
const ethers = require(‘ethers’);
javascript/typescript es6
import { ethers } from ‘ethers’;

如果你决定直接在web中使用ethers,那么你可以这样引入,出于安全考虑,通常最好复制一份 ethers-v4.min.js 到自己的应用程序服务器,如果快速原型体验,使用Ethers CDN应该足够了。


<script src="https://cdn.ethers.io/scripts/ethers-v4.min.js"
        charset="utf-8"
        type="text/javascript">
script>

创建钱包账号

web3的钱包通常具有这样的属性

  • 账号管理 (私钥):创建账号,进行私钥的导入导出,
  • 信息展示:主要是个人钱包的余额,
  • 转账功能:进行token的转账
    我们将通过ether.js进行实现

创建账号我们知道通常是有两种方式,这是由HD钱包涉及的加密算法BIP32,BIP44,BIP39所决定的(如果你还不知道,那就直接记住下面的就行了,毕竟会用才是最重要的)

  • 随机生成32个字节的数作为私钥
  • 通过助记词进行确定性推导得到私钥

使用随机数生成私钥

这是我们上面所说的生成私钥的第一种方法,随机生成32个字节的数作为私钥,我们正确导入ether.js之后,调用其中的ether.utils.randomBytes()方法就可以得到一个随机数了,然后再调用ether中的Wallet进行钱包连接,我们就得到了一个钱包实例

let privateKey = ethers.utils.randomBytes(32)
let wallet = ethers.Wallet(privateKey)

这时候我们看到的私钥是个字符集,如果想要转化为我们易于保存的字符串模式,就要调用另外一个工具函数

ethers.BigNumber.from(privateKey)._hex

这样我们就能够得到一个这样的私钥

0x29895776b4c571de60c35e243cb157dade634bc557b9b7090a13d93e48cfa99e

ethers.BigNumber.from()之前的调用方法为ethers.utils.bigNumberify,后来因为发现这个函数经常被使用,在ethers从v4版本更替到v5版本时与utils同级并且增加了更多的方法,现在我们可以看到很多偏老旧的文档中,这一点还没有进行修改

通过助记词的方式生成私钥

通过助记词生成私钥的方法是我们目前主流上非常流行的一种方法,主要流程是,首先生成一个随机数,然后通过随机数生成助记词,再通过助记词创建钱包

const rand = ethers.utils.randomBytes(12)
const mnemonic = ethers.utils.entropyToMnemonic(rand)
var path =  "m/60'/1'/0'/0/0";
//通过助记词创建钱包
  // 检查助记词是否有效。
        if (!ethers.utils.HDNode.isValidMnemonic(inputPhrase.val())) {
            return;
        }
console.log(mnemonic)
Wallet.fromMnemonic(mnemonic, path);

在这里可能有必要要介绍一下这个path,这是BIP44密钥路径的一个固定写法
指定了包含5个预定义树状层级的结构:
m / purpose’ / coin’ / account’ / change / address_index
m是固定的, Purpose也是固定的,值为44(或者 0x8000002C)
Coin type
这个代表的是币种,0代表比特币,1代表比特币测试链,60代表以太坊
完整的币种列表地址:https://github.com/satoshilabs/slips/blob/master/slip-0044.md
Account
代表这个币的账户索引,从0开始
Change
常量0用于外部(收款地址),常量1用于内部(也称为找零地址)。外部用于在钱包外可见的地址(例如,用于接收付款)。内部链用于在钱包外部不可见的地址,用于返回交易变更。 (所以一般使用0)
address_index
这就是地址索引,从0开始,代表生成第几个地址,官方建议,每个account下的address_index不要超过20

根据 EIP85提议的讨论以太坊钱包也遵循BIP44标准,确定路径是m/44’/60’/a’/0/n
a 表示帐号,n 是第 n 生成的地址,60 是在 SLIP44 提案中确定的以太坊的编码。所以我们要开发以太坊钱包同样需要对比特币的钱包提案BIP32、BIP39有所了解。

我们可以在控制台上看到我们的十二位助记词

sponsor donate gun victory song cigar wolf ski solid business pattern broccoli

直接创建一个钱包

ether给提供了一个非常简单的方法,可以让我们直接创建一个钱包

ethers.Wallet.createRandom()

就是这么一个简单的方法,直接随机创建一个钱包,然后我们可以在控制台看到这个实例数据

Wallet {_isSigner: true, address: '0x50321B8585B19D144E2924CB01BE023B752669C9', provider: null, _signingKey: ƒ, _mnemonic: ƒ}
address: "0x50321B8585B19D144E2924CB01BE023B752669C9"
provider: null
_isSigner: true
_mnemonic: () => {}
_signingKey: () => signingKey
mnemonic: (...)
privateKey: (...)
publicKey: (...)
[[Prototype]]: Signer

账号keyStore文件的导入导出

keystore详解

为什么需要keystore文件?

私钥其实就代表了一个账号,最简单的保管账号的方式就是直接把私钥保存起来,如果私钥文件被人盗取,我们的数字资产将洗劫一空。

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

Keystore 文件如何生成的

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

使用 KDF 生成秘钥

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

助记词推导出种子的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文件长什么样看一看吧,这样我们就更容易理解了

{  
   "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 文件

用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
    });

我们首先从html中获得到password,然后将此password作为参数来实现导入导出

  {
          setPassword(e.target.value);
        }}
      />
      
 //获得keyStore文件
  const putKeyStore = () => {
    walletInstance.encrypt(password).then((json: string) => {
      console.log(json);
      getKeyStore(json)
      try {
        var blob = new Blob([json], { type: "text/plain;charset=utf-8" });
        let blobUrl = window.URL.createObjectURL(blob);
        let link = document.createElement("a");
        link.download = "keyStore.txt" || "defaultName";
        link.style.display = "none";
        link.href = blobUrl;
        // 触发点击
        document.body.appendChild(link);
        link.click();
        // 移除
        document.body.removeChild(link);
      } catch (error) {
        console.error(error);
      }
    });
  };

文件导入

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]);

或者这样反向推导

  //反向推导出钱包地址
  const getKeyStore = (json:string)=>{
    ethers.Wallet.fromEncryptedJson(json,password).then(res=>{
      console.log(res);
    })
  

展示钱包信息,以及发起签名交易

我们其实可以发现,前面的介绍中,我们无论是生成私钥还是生成钱包,其实都会发现和以太坊网络并没有什么太大的关系,但是如果我们想要真的进行转账,交易查询余额等信息,那就必须要连接至以太坊网络才能够进行,

如果你原来接触过web3的话,那么你就一定知道,连接至eth网络一定是需要一个provider的,ether.js本身提供了非常多的连接provider的方法

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

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

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

连接Provider, 通常有一个参数network网络名称,取值有: homestead, rinkeby, ropsten, kovan。

    let provider = ethers.getDefaultProvider('ropsten');
    //activeWallet是我们前面创建的钱包实例
    activeWallet = walletInstance.connect(provider)

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

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

  //获取余额
    activeWallet.getBalance().then((res)=>{
      console.log(res);
    })
    //获取交易数量
    activeWallet.getTransactionCount().then((res)=>{
      console.log(res);
    })

发送签名交易

签名交易也称为离线交易(因为这个过程可以离线进行:在离线状态下对交易进行签名,然后把签名后的交易进行广播)。

尽管 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-155查询。

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

gasPrice和gasLimit: 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()

发送(广播)交易

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

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) {
        });

你可能感兴趣的