开发去中心化钱包
GitHub地址:https://github.com/lizhiren2016/vueWallet一、前言随着区块链越来越火热,数字货币钱包也随之被人重视。数字钱包的发展历程是从最初比特币的非确定性钱包,到确定性钱包,一直到我们现在最广为使用HD分层确定性钱包,而今天要分析的钱包就是现在最为流行的HD分层确定性钱包。HD钱包的英文全称是:Hierarchical Determinist...
GitHub地址:https://github.com/lizhiren2016/vueWallet
一、前言
随着区块链越来越火热,数字货币钱包也随之被人重视。数字钱包的发展历程是从最初比特币的非确定性钱包,到确定性钱包,一直到我们现在最广为使用HD分层确定性钱包,而今天要分析的钱包就是现在最为流行的HD分层确定性钱包。
HD钱包的英文全称是:Hierarchical Deterministic 之所以叫分层确定性钱包是因为私钥的衍生结构是树状结构,父密钥可以衍生一系列子密钥,每个子密钥又可以衍生出一系列孙密钥,以此类推,无限衍生。在创建钱包或者备份钱包的时候都会看到一堆英文单词(或者中文汉字),这些词就是助记词。
1.1 数字钱包概念
如果你还在被 HD 钱包 (分层确定性钱包)、BIP32、BIP44、BIP39 搞的一头雾水,接下来我们就来学习一下吧。
私钥和地址的关系如下:
1.2 如何创建账号
创建账号关键是生成一个私钥, 私钥是一个 32 个字节的数, 生成一个私钥在本质上在 1 到 2^256 之间选一个数字。
因此生成密钥的第一步也是最重要的一步,是要找到足够安全的熵源,即随机性来源,只要选取的结果是不可预测或不可重复的,那么选取数字的具体方法并不重要。
比如可以掷硬币 256 次,用纸和笔记录正反面并转换为 0 和 1,随机得到的 256 位二进制数字可作为钱包的私钥。
从编程的角度来看,一般是通过在一个密码学安全的随机源 (不建议大家自己去写一个随机数) 中取出一长串随机字节,对其使用 SHA256 哈希算法进行运算,这样就可以方便地产生一个 256 位的数字。
实际过程需要比较下是否小于 n-1(n = 1.158 * 10^77, 略小于 2^256),我们就有了一个合适的私钥。否则,我们就用另一个随机数再重复一次。这样得到的私钥就可以根据上面的方法进一步生成公钥及地址。
1.3 BIP32
钱包也是一个私钥的容器,按照上面的方法,我们可以生成一堆私钥(一个人也有很多账号的需求,可以更好保护隐私),而每个私钥都需要备份就特别麻烦的。
很最早期的比特币钱包就是就是这样,还有一个昵称:“Just a Bunch Of Keys (一堆私钥)“
为了解决这种麻烦,就有了 BIP32 提议: 根据一个随机数种子通过分层确定性推导的方式得到 n 个私钥,这样保存的时候,只需要保存一个种子就可以,私钥可以推导出来,如图:
补充说明下 BIP: Bitcoin Improvement Proposals 比特币改进建议,bip32 是第 32 个改进建议。
BIP32 提案的名字是:Hierarchical Deterministic Wallets, 就是我们所说的 HD 钱包。
根种子输入到 HMAC-SHA512 算法中就可以得到一个可用来创造主私钥 (m) 和 一个主链编码( a master chain code) 这一步生成的秘钥(由私钥或公钥)及主链编码再加上一个索引号,将作为 HMAC-SHA512 算法的输入继续衍生出下一层的私钥及链编码,如下图:
衍生推导的方案其实有两个:一个用父私钥推导(称为强化衍生方程),一个用父公钥推导。同时为了区分这两种不同的衍生,在索引号也进行了区分,索引号小于 2^31 用于常规衍生,而 2^31 到 2^32-1 之间用于强化衍生,为了方便表示索引号 i’,表示 2^31+i。
因此增加索引(水平扩展)及 通过子秘钥向下一层(深度扩展)可以无限生成私钥。
注意, 这个推导过程是确定(相同的输入,总是有相同的输出)也是单向的,子密钥不能推导出同层级的兄弟密钥,也不能推出父密钥。如果没有子链码也不能推导出孙密钥。现在我们已经对分层推导有了认识。
一句话概括下 BIP32 就是:为了避免管理一堆私钥的麻烦提出的分层推导方案。
1.4 秘钥路径及 BIP44
通过这种分层(树状结构)推导出来的秘钥,通常用路径来表示,每个级别之间用斜杠 / 来表示,由主私钥衍生出的私钥起始以 “m” 打头。因此,第一个母密钥生成的子私钥是 m/0。第一个公共钥匙是 M/0。第一个子密钥的子密钥就是 m/0/1,以此类推。
BIP44 则是为这个路径约定了一个规范的含义 (也扩展了对多币种的支持),BIP0044 指定了包含 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 有所了解。
一句话概括下 BIP44 就是:给 BIP32 的分层路径定义规范。
1.4 BIP39
BIP32 提案可以让我们保存一个随机数种子(通常 16 进制数表示),而不是一堆秘钥,确实方便一些,不过用户使用起来 (比如冷备份) 也比较繁琐,这就出现了 BIP39,它是使用助记词的方式,生成种子的,这样用户只需要记住 12(或 24)个单词,单词序列通过 PBKDF2 与 HMAC-SHA512 函数创建出随机种子作为 BIP32 的种子。
可以简单的做一个对比,下面那一种备份起来更友好:
// 随机数种子
090ABCB3A6e1400e9345bC60c78a8BE7
// 助记词种子
candy maple cake sugar pudding cream honey rich smooth crumble sweet treat
使用助记词作为种子其实包含 2 个部分:助记词生成及助记词推导出随机种子,下面分析下这个过程。
生成助记词
助记词生成的过程是这样的:先生成一个 128 位随机数,再加上对随机数做的校验 4 位,得到 132 位的一个数,然后按每 11 位做切分,这样就有了 12 个二进制数,然后用每个数去查 BIP39 定义的单词表,这样就得到 12 个助记词,这个过程图示如下:
下面是使用 bip39 生成生成助记词的一段代码:
var bip39 = require('bip39')
// 生成助记词
var mnemonic = bip39.generateMnemonic()
console.log(mnemonic)
助记词推导出种子
这个过程使用密钥拉伸(Key stretching)函数,被用来增强弱密钥的安全性,PBKDF2 是常用的密钥拉伸算法中的一种。
PBKDF2 基本原理是通过一个为随机函数 (例如 HMAC 函数),把助记词明文和盐值作为输入参数,然后重复进行运算最终产生生成一个更长的(512 位)密钥种子。这个种子再构建一个确定性钱包并派生出它的密钥。
密钥拉伸函数需要两个参数:助记词和盐。盐可以提高暴力破解的难度。 盐由常量字符串 “mnemonic” 及一个可选的密码组成,注意使用不同密码,则拉伸函数在使用同一个助记词的情况下会产生一个不同的种子,这个过程图示图下:
同样代码来表示一下:
var hdkey = require('ethereumjs-wallet/hdkey')
var util = require('ethereumjs-util')
var seed = bip39.mnemonicToSeed(mnemonic, "pwd");
var hdWallet = hdkey.fromMasterSeed(seed);
var key1 = hdWallet.derivePath("m/44'/60'/0'/0/0");
console.log("私钥:"+util.bufferToHex(key1._hdkey._privateKey));
var address1 = util.pubToAddress(key1._hdkey._publicKey, true);
console.log("地址:"+util.bufferToHex(address1));
console.log("校验和地址:"+ util.toChecksumAddress(address1.toString('hex')));
校验和地址是 EIP-55 中定义的对大小写有要求的一种地址形式。
密码可以作为一个额外的安全因子来保护种子,即使助记词的备份被窃取,也可以保证钱包的安全(也要求密码拥有足够的复杂度和长度),不过另外一方面,如果我们忘记密码,那么将无法恢复我们的数字资产。
一句话概括下 BIP39 就是:通过定义助记词让种子的备份更友好。
1.4 小结
HD 钱包(Hierarchical Deterministic Wallets)是在 BIP32 中提出的为了避免管理一堆私钥的麻烦提出的分层推导方案。
而 BIP44 是给 BIP32 的分层增强了路径定义规范,同时增加了对多币种的支持。
BIP39 则通过定义助记词让种子的备份更友好。
目前我们的市面上单到的以太币、比特币钱包基本都遵循这些标准。
2.1 去中心化网页钱包
先明确一下定义,什么是去中心化钱包,账号秘钥的管理,交易的签名,都是在客户端完成, 即私钥相关的信息都是在用户手中,钱包的开发者接触不到私钥信息。
对应的中心化钱包则是私钥由中心服务器托管,如交易所的钱包就是这种。
网页钱包,或者叫 web 钱包,是指钱包以网页的形式展现,去中心化网页钱包则交易的签名等操作是在浏览器里完成。其他形式的钱包,如 Android 钱包或 iOS 钱包其开发思路和 web 钱包一样。
二、钱包介绍
2.1 功能
(1)账号管理(主要是私钥的管理):根据随机助记词创建钱包、钱包的导入导出(根据自定义助记词导入钱包、根据KeyStore导入钱包)
(2)账号信息展示:如以太币余额、Token(代币)余额。
(3)转账功能:发送以太币及发送 Token(代币)
这些功能将基于 ethers.js、web3.js 进行开发, ethers.js 和 web3.js 一样,都是一套和以太坊区块链进行交互的库,不仅如此,ethers.js 还对 BIP 39 等相关的提案进行了实现,可以在这个链接阅读其文档。
2.2 特色
- 该钱包的特点就是简单,只具备一个数字钱包最基本的功能:密钥管理(私钥的 生成 + 导入 + 备份)和交易。密钥生成主要使用原生的bip-39库生成助记词(可自定义助记词或随机助记词)然后进一步生成私钥。
- 因为使用的是原生的库进行私钥生成,私钥生成过程会比较接触底层,备份使用Keystore进行保存。
- 以太坊的 keystore文件Linux 系统存储在/home_path/.ethereum/keystore或者Windows系统存储在C:\Users\Appdata/Roaming/Ethereum/keystore)是你独有的、用于签署交易的以太坊私钥的加密文件。如果你丢失了这个文件,你就丢失了私钥,意味着你失去了签署交易的能力,意味着你的资金被永久的锁定在了你的账户里。
三、功能介绍
3.1 创建钱包
- 直接生成 32 个字节的数当成私钥
- 通过助记词进行确定性推导出私钥
方式一,可以使用 ethers.utils.randomBytes 生成一个随机数,然后使用这个随机数来创建钱包,如代码:
// 使用随机数作为私钥创建钱包账号
wallet.RandomNumberGeneration = function (callback) {
try {
const privateKey = ethers.utils.randomBytes(32)
const wallet = new ethers.Wallet(privateKey)
console.log('账号地址: ' + wallet.address)
// 注意 ethers.utils.randomBytes 生成的是一个字节数组,
// 如果想用十六进制数显示出来表示,需要转化为 BigNumber 代码如下:
let randomNumber = ethers.utils.bigNumberify(privateKey)
console.log(randomNumber._hex)
callback(null, wallet)
} catch (err) {
callback(err, null)
}
}
方式二,这是目前主流常见钱包的方式,主要使用原生的bip-39库生成助记词。
支持校验密码是否为空,以及两次密码需要一致,点击创建。关键代码如下:
// 验证账户密码
validatePassword (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
const {password, repeatPassword} = this.createAccountForm
if (password === repeatPassword) {
this.viewType = 2
} else {
alert('两次密码不一致')
}
}
})
},
支持校验助记词的格式,可以自己生成助记词也可以直接点击生成系统随机助记词,生成成功后进入账户信息展示页面
这里会将账户相关信息展示出来,需要自己备份好,并且可以将keystore文件下载至本地保存,关键代码如下:
// 生成助记词
generateMnemonic () {
let {randomSeed} = this.generateMnemonicForm
const {password} = this.createAccountForm
this.disabled = true
if (randomSeed) {
// 校验用户输入的助记词
if (randomSeed.split(' ').length !== 12 || !myWallet.util.validSeed(randomSeed)) {
alert('助记词不正确')
return
}
}
// 生成钱包,传入一个助记词,返回一个keystore
myWallet.wallet.randomSeedGenerate(randomSeed, this.hdPathString, (err, data) => {
if (err) {
this.disabled = false
console.warn(err.message)
return
}
// 账户的keystore
this.keystore = data.keystore
this.generateMnemonicForm.randomSeed = data.randomSeed
// 根据用户输入的密码,生成地址
this.generateAddress(password)
})
},
// 生成账户地址
generateAddress (password) {
// 验证密钥是否生成成功
if (typeof this.keystore.getHexAddress !== 'function') {
this.disabled = false
return false
}
// 将生成出来的私钥、公钥等信息赋值展示到页面上给用户
this.privateKey = this.keystore.getHexPrivateKey()
this.address = this.keystore.getHexAddress(true)
// 通过用户传入的password对私钥进行加密,并生成keystore文件
this.keystore.toV3String(password, {}, (err, v3Json) => {
if (err) {
this.disabled = false
console.warn(err.message)
return
}
// 返回keystore JSON的数据
this.keystoreJson = v3Json
// 生成下载文件
this.keystoreJsonDataLink = encodeURI('data:application/json;charset=utf-8,' + this.keystoreJson)
// 生成文件名字
this.fileName = `${this.keystore.getV3Filename()}.json`
// 根据 keystoreJson、password 导入wallet
myWallet.wallet.fromEncryptedJson(v3Json, password, (err, wallet) => {
if (err) {
alert(err.message())
return
}
// 赋值给变量,方便传递wallet到下一个页面
this.wallet = wallet
this.viewType = 3
this.disabled = false
})
})
},
3.2详细解读 Keystore 文件
为什么需要 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 文件怎么产生的了。
{
"version": 3,
"id": "a6fc6521-d311-4c44-9076-51fb2cb03ef7",
"address": "05064eae4dc2225df937aa943ebfb346e1f059b6",
"crypto": {
"ciphertext": "a87330dbbe1e2ef938ac24b66e9babcc744943bbadcf24a0a4e39e6b20d67678",
"cipherparams": {
"iv": "05910febccb12c681ed2f71c31bc1a21"
},
"cipher": "aes-128-ctr",
"kdf": "scrypt",
"kdfparams": {
"dklen": 32,
"salt": "4bc85d4bc26f507dcb0b926c64c6f936be5b9d16c28e2551d3a6b32e3b2897fd",
"n": 262144,
"r": 8,
"p": 1
},
"mac": "7c2b806fcf9b40787f3fa15d6ef5b3970c60de0e7d38304e7b02c6cd36c8a162"
}
}
合来解读一下各个字段:
- 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 文件的产生:
- 使用 scrypt 函数 (根据密码 和 相应的参数) 生成秘钥
- 使用上一步生成的秘钥 + 账号私钥 + 参数 进行对称加密
- 把相关的参数 和 输出的密文 保存为以上格式的 JSON 文件
如何确保密码是对的?
当我们在使用 Keystore 文件来还原私钥时,依然是使用 kdf 生成一个秘钥,然后用秘钥对 ciphertext 进行解密,其过程如下:
此时细心的会发现,无论使用说明密码,来进行这个操作,都会生成一个私钥,但是最终计算的以太坊私钥到底是不是正确的,却不得而知。
这就是 keystore 文件中 mac 值的作用。mac 值是 kdf 输出 和 ciphertext 密文进行 SHA3-256 运算的结果,显然密码不同,计算的 mac 值也不同,因此可以用来检验密码的正确性。检验过程用图表示如下:
现在我们以解密的角度完整的梳理下流程,就可以得到以下图:
关键代码如下:
const ethUtil = require('ethereumjs-util')
const crypto = require('crypto')
const scrypt = require('scrypt-async')
const uuid = require('uuid')
// const util = require('./util')
const keystore = function (privateKey) {
if (!privateKey) {
throw new Error('Please enter private key.')
}
if (!ethUtil.isValidPrivate(privateKey)) {
throw new Error('Private key is invalid.')
}
this._privateKey = privateKey
this._publicKey = ethUtil.privateToPublic(privateKey)
}
Object.defineProperty(keystore.prototype, 'privateKey', {
get: function () {
return this._privateKey
}
})
Object.defineProperty(keystore.prototype, 'publicKey', {
get: function () {
return this._publicKey
}
})
Object.defineProperty(keystore.prototype, 'hdKey', {
get: function () {
return this._hdKey
},
set: function (hdKey) {
this._hdKey = hdKey
}
})
keystore.prototype.getAddress = function () {
return ethUtil.publicToAddress(this._publicKey)
}
keystore.prototype.getHexAddress = function (withPrefix) {
if (withPrefix) {
return '0x' + this.getAddress().toString('hex')
}
return this.getAddress().toString('hex')
}
keystore.prototype.getV3Filename = function () {
const date = new Date()
return [
'UTC--',
date.toJSON().replace(/:/g, '-'),
'--',
this.getHexAddress()
].join('')
}
keystore.prototype.toJson = function (password, opts) {
return master.toV3String(password, opts)
}
keystore.prototype.getPrivateKey = function () {
return this._privateKey
}
keystore.prototype.getHexPrivateKey = function () {
return this._privateKey.toString('hex')
}
keystore.prototype.getPublicKey = function () {
return this._publicKey
}
keystore.prototype.getHexPublicKey = function () {
return this._publicKey.toString('hex')
}
keystore.prototype.toV3String = function (password, options, callback) {
this.toV3(password, options, function (err, v3) {
if (err) {
callback(err, null)
return
}
callback(null, JSON.stringify(v3))
})
}
// see https://github.com/ethereum/wiki/wiki/Web3-Secret-Storage-Definition/b66dfbe3e84287f6fa61c079007255270cd20c14
// 通过用户传入的password对私钥进行加密,并生成keystore文件
keystore.prototype.toV3 = function (password, options, callback) {
try {
// 先检查私钥是否生成
if (!this._privateKey) {
throw new Error('Please generate wallet with private key.')
}
// 选项默认为空
options = options || {}
// 用以加密以太坊私钥的强对称加密算法默认:aes-128-ctr
const cipherAlgorithm = options.cipher || 'aes-128-ctr'
const salt = options.salt || crypto.randomBytes(32) // 盐值
// aes-128-ctr 加密所用到的初始化向量
const iv = options.iv || crypto.randomBytes(16)
const id = uuid.v4({ random: options.uuid || crypto.randomBytes(16) }) // id
const kdf = options.kdf || 'scrypt' // 指派密钥派生函数,默认scrypt
const kdfparams = {
// kdf 密钥生成时所需要的参数
dklen: options.dklen || 32,
salt: salt.toString('hex')
}
const cb = function (derivedKey) {
// 利用kdf生成完加密密钥之后调用该函数进一步利用加密密钥生成ciphertext
derivedKey = new Buffer(derivedKey)
let cipher = crypto.createCipheriv(cipherAlgorithm, derivedKey.slice(0, 16), iv)
if (!cipher) {
callback(new Error('Unsupported cipher algorithm.'), null)
return
}
// 利用kdf密钥生成函数生成的加密密钥对私钥加密得到私钥加密密文 ==>ciphertext
const ciphertext = Buffer.concat([ cipher.update(this.privateKey), cipher.final() ])
// 计算校验值 mac
const mac = ethUtil.keccak(Buffer.concat([ derivedKey.slice(16, 32), new Buffer(ciphertext, 'hex') ]))
// 拼接全部生成完毕的数据(keystore)并返回
const v3 = {
version: 3,
id: id,
address: this.getHexAddress(),
crypto: {
ciphertext: ciphertext.toString('hex'),
cipherparams: {
iv: iv.toString('hex')
},
cipher: cipherAlgorithm,
kdf: kdf,
kdfparams: kdfparams,
mac: mac.toString('hex')
}
}
callback(null, v3)
}.bind(this)
if (kdf === 'pbkdf2') {
kdfparams.c = options.c || 262144
kdfparams.prf = 'hmac-sha256'
crypto.pbkdf2(new Buffer(password), salt, kdfparams.c, kdfparams.dklen, 'sha256', function (err, derivedKey) {
if (err) {
callback(err, null)
return
}
cb(derivedKey)
})
} else if (kdf === 'scrypt') {
// const saltUse = util.bufferToArray(salt)
kdfparams.n = options.n || 262144
kdfparams.r = options.r || 8
kdfparams.p = options.p || 1
scrypt(password, salt, {N: kdfparams.n, r: kdfparams.r, p: kdfparams.p, dklen: kdfparams.dklen, encoding: 'binary'}, cb)
} else {
throw new Error('Unsupported key derivation function.')
}
} catch (err) {
callback(err, null)
}
}
module.exports = exports = keystore
3.3 使用 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 网络名称,取值有: homestead, rinkeby, ropsten, kovan, 关于 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 通信及跨域。
3.4 展示钱包详情:查询余额及 Nonce
连接到以太坊网络之后,就可以向网络请求余额以及获取账号交易数量
/**
// 获取以太账户余额
wallet.getBalance = function (activeWallet, callback) {
activeWallet.getBalance('pending').then(function (balance) {
const formatBalance = ethers.utils.formatEther(balance, { commify: true })
callback(null, formatBalance)
}, function (err) {
callback(err, null)
})
}
// 获取TransactionCount
wallet.getTransactionCount = function (activeWallet, callback) {
activeWallet.getTransactionCount('pending').then(function (transactionCount) {
callback(null, transactionCount)
}).catch(err => {
callback(err, null)
})
}
3.5 发送签名交易
我们要完成一个钱包,必须要发送一个签名交易,签名交易也称为离线交易(因为这个过程可以离线进行:在离线状态下对交易进行签名,然后把签名后的交易进行广播)。
尽管 Ethers.js 提供了非常简洁的 API 来发送签名交易,但是探究下简洁 API 背后的细节依然会对我们有帮助,这个过程大致可分为三步:
- 签构造交易
- 交易签名
- 发送(广播)交易
构造交易
先来看看一个交易长什么样子:
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,这些服务通常还会参考当前交易池内交易数量及价格,可参考性更强,常规的一个做法是利用这些接口给用户一个参考值,然后用户可以根据参考值进行微调。
交易签名
在构建交易之后,就是用私钥对其签名,代码如下:
// 签名交易
signTransaction (formName) {
const {to, nonce, gasPrice, gasLimit, value} = this.form
const {utils} = this.web3
this.$refs[formName].validate(async (valid) => {
if (valid) {
const txParams = {
to: to,
chainId: 3,
value: utils.toHex(utils.toWei(value, 'ether')),
nonce: utils.toHex(nonce),
gasPrice: utils.toHex(utils.toWei(gasPrice, 'gwei')),
gasLimit: utils.toHex(gasLimit)
}
let valueTx = myWallet.tx.valueTx(txParams)
const privateKey = Buffer.from(
this.wallet.privateKey.slice(2),
'hex'
)
// 用私钥对其签名
valueTx.sign(privateKey)
const signedTransaction = '0x' + valueTx.serialize().toString('hex')
// 发送(广播) 交易
this.sendTransaction(signedTransaction)
}
})
},
发送(广播)交易
代码如下:
// 发送交易
sendTransaction (signedTransaction) {
this.web3.eth.sendSignedTransaction(signedTransaction, function (err, txId) {
if (err) {
console.warn(err.message)
return
}
this.jump('personal', this.wallet)
}.bind(this))
},
通过这三步就完成了发送签名交易的过程,ethers.js 里提供了一个简洁的接口,来完成所有这三步操作 (强调一下,签名已经在接口里帮我们完成了),接口如下:
activeWallet.sendTransaction({
to: targetAddress,
value: amountWei,
gasPrice: activeWallet.provider.getGasPrice(),
gasLimit: 21000,
}).then(function(tx) {
});
3.6 发送 Token (代币)
合约 ABI 信息
首先我们需要明白,进行 Token 转账的时候,其实是在调用合约的转账函数,而要调用一个合约的函数,需要知道合约的 ABI 信息
要其次 通常我们所说的 Token, 其实指的是符合 ERC20 标准接口的合约, ERC20 接口定义如下:
contract ERC20Interface {
string public constant name = "Token Name";
string public constant symbol = "SYM";
uint8 public constant decimals = 0;
function totalSupply() constant returns (uint theTotalSupply);
function balanceOf(address _owner) constant returns (uint balance);
function transfer(address _to, uint _value) returns (bool success);
function transferFrom(address _from, address _to, uint _value) returns (bool success);
function approve(address _spender, uint _value) returns (bool success);
function allowance(address _owner, address _spender) constant returns (uint remaining);
event Transfer(address indexed _from, address indexed _to, uint _value);
event Approval(address indexed _owner, address indexed _spender, uint _value);
}
ABI 全称是 Application Binary Interface,它就是合约接口的描述,因此有了合约的接口定义,就可以很容易通过编译拿到 ABI 信息,比如像下图在 Remix 的编译选项卡就可以直接复制 ABI。
生成的 ABI 描述大概长这样:
module.exports = [{
'constant': true,
'inputs': [],
'name': 'name',
'outputs': [{'name': '', 'type': 'string'}],
'payable': false,
'stateMutability': 'view',
'type': 'function'
}, {
'constant': false,
'inputs': [{'name': '_spender', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}],
'name': 'approve',
'outputs': [{'name': 'success', 'type': 'bool'}],
'payable': false,
'stateMutability': 'nonpayable',
'type': 'function'
}, {
'constant': true,
'inputs': [],
'name': 'totalSupply',
'outputs': [{'name': '', 'type': 'uint256'}],
'payable': false,
'stateMutability': 'view',
'type': 'function'
}, {
'constant': false,
'inputs': [{'name': '_from', 'type': 'address'}, {'name': '_to', 'type': 'address'}, {
'name': '_value',
'type': 'uint256'
}],
'name': 'transferFrom',
'outputs': [{'name': 'success', 'type': 'bool'}],
'payable': false,
'stateMutability': 'nonpayable',
'type': 'function'
}, {
'constant': true,
'inputs': [],
'name': 'decimals',
'outputs': [{'name': '', 'type': 'uint8'}],
'payable': false,
'stateMutability': 'view',
'type': 'function'
}, {
'constant': true,
'inputs': [{'name': '_owner', 'type': 'address'}],
'name': 'balanceOf',
'outputs': [{'name': 'balance', 'type': 'uint256'}],
'payable': false,
'stateMutability': 'view',
'type': 'function'
}, {
'constant': true,
'inputs': [],
'name': 'symbol',
'outputs': [{'name': '', 'type': 'string'}],
'payable': false,
'stateMutability': 'view',
'type': 'function'
}, {
'constant': false,
'inputs': [{'name': '_to', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}],
'name': 'transfer',
'outputs': [{'name': 'success', 'type': 'bool'}],
'payable': false,
'stateMutability': 'nonpayable',
'type': 'function'
}, {
'constant': true,
'inputs': [{'name': '_owner', 'type': 'address'}, {'name': '_spender', 'type': 'address'}],
'name': 'allowance',
'outputs': [{'name': 'remaining', 'type': 'uint256'}],
'payable': false,
'stateMutability': 'view',
'type': 'function'
}, {'inputs': [], 'payable': false, 'stateMutability': 'nonpayable', 'type': 'constructor'}, {
'anonymous': false,
'inputs': [{'indexed': true, 'name': '_from', 'type': 'address'}, {
'indexed': true,
'name': '_to',
'type': 'address'
}, {'indexed': false, 'name': '_value', 'type': 'uint256'}],
'name': 'Transfer',
'type': 'event'
}, {
'anonymous': false,
'inputs': [{'indexed': true, 'name': '_owner', 'type': 'address'}, {
'indexed': true,
'name': '_spender',
'type': 'address'
}, {'indexed': false, 'name': '_value', 'type': 'uint256'}],
'name': 'Approval',
'type': 'event'
}]
它是一个 JSON 形式的数组,数组里每一个元素,都是对函数接口的描述,在外部调用合约的时候就需要遵从这个接口,以上面的接口为例,通常一个接口描述包含下述几个字段:
- name: 函数会事件的名称
- type: 可取值有 function,constructor,fallback,event
- inputs: 函数的输入参数,每个参数对象包含下述属性:
- name: 参数名称
- type: 参数的规范型 (Canonical Type)
- outputs: 一系列的类似 inputs 的对象,如果无返回值时,可以省略
- constant: true 表示函数声明自己不会改变状态变量的值
- payable: true 表示函数可以接收 ether,否则表示不能
接下来在构造合约对象就需要是使用 ABI。
构造合约对象
ethers.js 构造合约对象很简单,仅需要提供三个参数给 ethers.Contract 构造函数,代码如下:
/**
* 初始化合约
* @param address {String} 合约地址
* @param network {string} 网络地址
* @returns {Contract}
*/
wallet.newContract = function (address, network) {
const defaultProvider = ethers.getDefaultProvider(network || 'ropsten')
return new ethers.Contract(address, abi, defaultProvider)
}
获取 Token 余额及转移 Token
对应的逻辑代码也很简单:
/**
* 获取合约账户余额
* @param contract {Object} 合约对象
* @param address {String} 查询的地址
* @param callback
*/
wallet.getContractBalance = function (contract, address, callback) {
contract.balanceOf(address).then(function (balance) {
callback(null, new BigNumber(balance).toNumber())
}).catch(err => {
callback(err, null)
})
}
转移 Token
交易成功后会返回本次交易的tx对象
关键代码如下:
// 发送交易
sendTransaction (formName) {
const {to, value, decimals, balance, gasLimit} = this.form
this.$refs[formName].validate(async function (valid) {
if (valid) {
// 验证码账户的余额是否能支持本次交易
if ((value - balance) / `1e+${decimals}` > 0) {
return alert('账户余额不足以本次交易!')
}
// 获取最近几个历史区块 gas price 的中位数
const gasPrice = await this.activeWallet.provider.getGasPrice()
// 连接一个activeWallet
let contractWithSigner = this.contract.connect(this.activeWallet)
contractWithSigner.transfer(to, value, {
gasPrice: gasPrice,
gasLimit: gasLimit
}).then(function (tx) {
console.log(tx)
alert('交易成功!')
this.jump('personal', this.wallet)
}.bind(this))
}
}.bind(this))
}
上述有一个地方都要注意一下,在合约调用 transfer 之前, 需要连接一个 signer,因为发起交易的时候需要用它来进行签名,在 ethers.js API 里 Wallet 是 signer(抽象类)的实现类。
所有会更改区块链数据的函数都需要关联签名器,如果是视图函数则只需要连接 provider。
ethers.js 的 Contract 提供了一个非常方便方法:contract.estimate.functionName 来计算预测交易的 gasLimit。
在发起交易的时候,可以提供一个可选的 Overrides 参数,在这个参数里可以指定如交易的 gasLimit 、 gasPrice,如果我们不指定这个参数时,会默认使用 contract.estimate 获得的值作为 gasLimit,以及 provider.getGasPrice () 的值来指定 gasPrice。
哈哈,恭喜大家,到这里就完整的实现了一个基于以太坊去中心化网页钱包。
更多推荐
所有评论(0)