本篇教程不会过多的讲述关于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的钱包通常具有这样的属性
创建账号我们知道通常是有两种方式,这是由HD钱包涉及的加密算法BIP32,BIP44,BIP39所决定的(如果你还不知道,那就直接记住下面的就行了,毕竟会用才是最重要的)
这是我们上面所说的生成私钥的第一种方法,随机生成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文件,因此对称加密秘钥(注意它其实也是发起交易时需要的解密秘钥)的选择就非常关键,这个秘钥是使用KDF算法推导派生而出。因此在完整介绍Keystore 文件如何生成前,有必要先介绍一下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文件长什么样看一看吧,这样我们就更容易理解了
{
"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
}
来解读一下各个字段:
我们来完整梳理一下 Keystore 文件的产生:
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)
连接到以太坊网络之后,就可以向网络请求余额以及获取账号交易数量,使用一下API:
//获取余额
activeWallet.getBalance().then((res)=>{
console.log(res);
})
//获取交易数量
activeWallet.getTransactionCount().then((res)=>{
console.log(res);
})
签名交易也称为离线交易(因为这个过程可以离线进行:在离线状态下对交易进行签名,然后把签名后的交易进行广播)。
尽管 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,这些服务通常还会参考当前交易池内交易数量及价格,可参考性更强,
常规的一个做法是利用这些接口给用户一个参考值,然后用户可以根据参考值进行微调。
在构建交易之后,就是用私钥对其签名,代码如下:
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) {
});