以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签名交易、发送Token(代币),这是第一篇,主要介绍钱包将实现哪些功能及怎么创建钱包账号,本钱包是基于ethers.js 进行开发。
先明确一下定义,什么是去中心化钱包,账号秘钥的管理,交易的签名,都是在客户端完成, 即私钥相关的信息都是在用户手中,钱包的开发者接触不到私钥信息。
对应的中心化钱包则是私钥由中心服务器托管,如交易所的钱包就是这种。
网页钱包,或者叫web钱包,是指钱包以网页的形式展现,去中心化网页钱包则交易的签名等操作是在浏览器里完成。
其他形式的钱包,如Android钱包或iOS钱包其开发思路和web钱包一样,因此文本对开发其他平台的钱包也有参考意义,不过本系列文章主要侧重在钱包功能的实现,并未过多考虑用户体验。
一个钱包通常主要包含的功能有:
这些功能将基于 ethers.js 进行开发, ethers.js 和web3.js 一样,也是一套和以太坊区块链进行交互的库,不仅如此,ethers.js 还对BIP 39等相关的提案进行了实现,可以在这个链接阅读其文档。
这些功能主要表现为钱包的两个界面,一个界面是:账号管理,一个界面是进行账号信息展示及转账。下面逐个进行介绍
读过上一篇文章理解开发HD 钱包涉及的 BIP32、BIP44、BIP39的同学,会知道创建账号,可以有两种方式:
即方式一,可以使用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
以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签名交易、发送Token(代币),这是第二篇,主要介绍钱包账号导出与导入,将对Keystore文件的生成的原理进行介绍。
在上一篇文章,介绍了如何使用私钥及助记词来创建账号,如果是使用已有的私钥及助记词,这其实也是账号导入的过程。
有一些同学会问,我的账号是Geth生成的,如何导入到钱包呢?使用Geth的同学,应该知道Geth在创建账号时会生成一个对应keystore JSON文件,Keystore文件存储加密后的私钥信息,因此我们需要做的就是导入这个Keystore文件,这个文件通常在同步区块数据的目录下的keystore文件夹(如: ~/.ethereum/keystore)里。
尽管在ethers.js 中,简单的使用一个函数就可以完成keystore文件的导入,不过理解Keystore 文件的作用及原理还是非常有必要的,当然如果你是在没有兴趣,可以直接跳到本文最后一节:使用ethers.js 实现账号导出导入。
通过这篇文章理解开发HD 钱包涉及的 BIP32、BIP44、BIP39,私钥其实就代表了一个账号,最简单的保管账号的方式就是直接把私钥保存起来,如果私钥文件被人盗取,我们的数字资产将洗劫一空。
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 文件怎么产生的了。
{
"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 文件的产生:
1. 使用scrypt函数 (根据密码 和 相应的参数) 生成秘钥
2. 使用上一步生成的秘钥 + 账号私钥 + 参数 进行对称加密。
3. 把相关的参数 和 输出的密文 保存为以上格式的 JSON 文件
当我们在使用Keystore文件来还原私钥时,依然是使用kdf生成一个秘钥,然后用秘钥对ciphertext进行解密,其过程如下:
此时细心的同学会发现,无论使用说明密码,来进行这个操作,都会生成一个私钥,但是最终计算的以太坊私钥到底是不是正确的,却不得而知。
这就是 keystore 文件中 mac 值的作用。mac 值是 kdf输出 和 ciphertext 密文进行SHA3-256运算的结果,显然密码不同,计算的mac 值也不同,因此可以用来检验密码的正确性。检验过程用图表示如下:
现在我们以解密的角度完整的梳理下流程,就可以得到以下图:
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
以太坊去中心化网页钱包开发系列,将从零开始开发出一个可以实际使用的钱包,本系列文章是理论与实战相结合,一共有四篇:创建钱包账号、账号Keystore文件导入导出、展示钱包信息及发起签名交易、发送Token(代币),这是第三篇介绍使用ethers.js的钱包对象获取相关信息及发起你离线交易。
我们前面两篇文章介绍创建(或导入)钱包账号的过程都是是离线的,即不需要依赖以太坊网络即可创建钱包账号,但如果想获取钱包账号的相关信息,比如余额、交易记录,发起交易的话,就需要让钱包连上以太坊的网络。
不管是在 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通信及跨域,
连接到以太坊网络之后,就可以向网络请求余额以及获取账号交易数量,使用一下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背后的细节依然会对我们有帮助,这个过程大致可分为三步:
先来看看一个交易长什么样子:
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
: 是交易时附加的消息,如果是对合约地址发起交易,这会转化为对合约函数的执行,可参考:如何理解以太坊ABInonce
: 交易序列号chainId
: 链id,用来去区分不同的链(分叉链)id可在EIP-55查询。
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()
代码参考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) {
});
先来看看发送交易的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);
});
})
哈哈哈~, 干活介绍到这里,现在夹带一点私货,有到了推广时间了,完整源码请订阅深入浅出区块链技术小专栏查看,赶紧订阅吧,走过路过,不容错过。