Truffle default web3 HttpProvider
定义
Truffle[1] 是以太坊上智能合约的开发环境,类似Java语境下的Maven或者Gradle工具,但是内涵更加丰富,除了编译链接,它还兼顾了智能合约的部署,部署需要适配多样的目标环境,例如本地Ganache模拟的开发网络、以太坊测试网络(Ropsten Rinkeby or Kovan Net)、以太坊主网(Main Net)。Truffle 为此提供了provider配置选项,默认使用Web3JS中定义的Web3.providers.HttpProvider
,它会使用host
和option
选项构造出目标地址http://<host>:<port>
,如下所示。
module.exports = {
networks: {
development: {
host: "localhost",
port: 8545,
network_id: "*" // Match any network id
}
}
};
一般来说,我们使用Ganache-cli(以太坊客户端的简单实现)模拟出一个以太坊节点,然后监听在8545端口,形成这套本地开发环境就足够支持调试合约。不过我们得了解,Truffle和Ganache在这里隐藏了很多细节,仔细思考一下以太坊的编程模型,创建一份智能合约本质上就是发送一条交易,即通过RPC调用sendTransaction[2]或sendRawTransaction[3]方法,这两者的区别在于后者发送的数据是签过名的,而前者没有。因为Truffle的配置文件并没有声明钱包(即公私钥,公钥用来产生地址和验证交易签名,私钥用来给交易数据签名),所以Web3.providers.HttpProvider
只能利用sendTransaction创建合约。那么问题来了,创建合约的这条交易数据是什么时候被签名的呢?
签名的时机
当调用sendTransaction函数创建合约时,合约数据是没有被签名的,那么很容易想到,唯一能签名的地方就是以太坊的客户端了。在开发环境下,Ganache启动时会自动帮我们生成10个账号,也即10对公私钥。当我们使用Truffle部署(创建)合约时,默认会使用第一个账号web3.eth.accounts[0]
对应的私钥签名合约数据。为了验证假设的正确性,我们可以设计两个试验。
Ganache-cli 在执行部署合约时,会把Truffle调用的JSON RPC方法名打印出来,所以按照以太坊JSON RPC规范的定义[4],一定会存在获取所有账号的调用eth_accounts
。
JSON RPC
为了简单验证,我们用truffle init
创建一个项目,并启动ganache-cli
命令行程序。当执行truffle deploy
命令时,Ganache的命令行中会打印出如下的RPC命令。
net_version
eth_accounts
eth_getBlockByNumber
...
其中,net_version
返回了当前网络的ID,对于以太坊而言,1
是主网,3
是测试网络Ropsten,4
是测试网络Rinkeby,42
是测试网络Kovan。
而eth_accounts
是在migrate(truffle deploy的别名)过程中被调用到的,具体逻辑我们可以查看truffle-migrate/migration.js文件,文件中有一个异步公开的async run(option, callback)
函数,它调用了子函数self._load(options, context, deployer, resolver, callback)
,这个load函数的用途是加载账号等资源并执行部署任务。代码如下:
const Deployer = require("truffle-deployer");
const Require = require("truffle-require");
// self._load(options, context, deployer, resolver, callback)
const accounts = await context.web3.eth.getAccounts(); // 获取账号
...
Require.file(requireOptions, async (err, fn) => {
...
try {
const migrateFn = fn(deployer, options.network, accounts);
await self._deploy(options, deployer, resolver, migrateFn, callback);
} catch (err){
callback(err);
}
});
这段代码中有两处需要特别留意。第一处就是通过RPCeth_accounts
获取账号,这个验证了我们之前的假设。第二处是Require.file(..., async (err, fn) => {...})
中的fn回调函数,这个回调函数其实就是Truffle项目中migrations目录下的迁移脚本里module.exports
导出的函数,例如:1_initial_migration.js
var Migrations = artifacts.require("./Migrations.sol");
module.exports = function(deployer) {
deployer.deploy(Migrations);
};
详细的调用逻辑需要到truffle-require/require.js中寻找,大意是Require.file(..., done)
提供的done
回调参数,最后函数体中回调了done(null, m.exports)
。
当然,以上的分析都是细节。可以学到的重要的知识点是,Truffle的迁移脚本中导出的函数其实接收了三个参数,所以可写成如下方式:
module.exports = function(deployer, network, accounts) {
deployer.deploy(Migrations);
};
这样设计有个很有用的地方,部署合约时,可以切换成不同的账号。
到这里,虽然我们验证了Truffle在部署时,确实会获取账号eth_accounts
,但是还没有验证Truffle是否会取用第一个账号,而且我们最终的目的是验证签名发生在以太坊的客户端上,此处的客户端便是Ganache。接下来,我们设计一个把第一个账号锁起来的试验,按照定义,所谓锁账号[5],就是把账号对应的私钥从内存中移除,使得该账号无法发送交易。如果试验结果是Truffle无法部署合约,那么就验证了假设,即签名发生在以太坊客户端上,和Truffle无关。这点很重要,因为它是我们在项目中使用Truffle HD Wallet Provider的理由之一。
锁账号
按照试验设计思路,Ganache启动完毕,我们通过命令truffle console
直接连接到Ganache客户端上。假设此处我们第一个账号地址是0x2462faca627e2b8511a7c362e4e0bf524b7fa368
,执行命令如下:
web3.eth.personal.lockAccount('0x2462faca627e2b8511a7c362e4e0bf524b7fa368')
true
同时,Ganache的控制台也会输出personal_lockAccount
RPC命令。
然后,我们开始执行部署命令truffle deploy
,不出所料,部署失败,抛出的错误是"Migrations" -- Returned error: signer account is locked。而在另一边,Ganache控制台停在了eth_sendTransaction
RPC命令那一步。
小结
从上面两个试验的结果得出结论:在默认Provider是Web3.providers.HttpProvider
情况下,Truffle部署合约并不会签名交易数据,而是交由以太坊客户端处理,以太坊客户端会找到未锁的账号,拿出私钥对交易数据进行签名,然后发送到当前网络。
不过,这种部署方式显然存在问题。第一,长期不锁账号,想要部署就必须保持客户端中有未锁的账号,这样很不安全。而且通过暴露Personal RPC接口以便解锁账号就会涉及密码的传输问题,也很不安全,尤其是暴露在互联网上;第二,我们部署合约的客户端可能并不是自己的,很多第三方提供了现成的API,比如:infrua,人家比我们搭建的客户端要稳定。那么此时你不可能要求infrua[6]解锁我们自己的账号,也不大放心把私钥放到它上面。
有了上面提到的两个问题,自然而然我们需要一个更好的部署方案了,这个方案就是Truffle HDWallet Provider.
Truffle HDWallet Provider
定义
truffle-hdwallet-provider[7] 是基于HD Wallet(可以从我之前介绍BIP32、BIP39和BIP44了解)的Web3 Provider。Wallet就意味着公私钥,所以不难想象它就是预先用来对合约数据进行签名的,然后调用sendRawTransaction把创建合约的交易发送到网络。那么,Truffle是如何做到的呢?我们先列出几个关键概念,然后用代码解释。
关键概念
- Web3 provider engine
- HookedSubprovider
Web3 provider engine
要弄清楚Web3 provider engine,首先得搞明白什么是Web3。Web3[8]是一组和以太坊客户端交互的JSON RPC API的定义。而Provider就是执行RPC命令的程序,例如专门设计用HTTP请求发起JSON RPC调用的HttpProvider就是其中之一。
Web3 provider engine是MetaMask[9]这个组织下一款开源工具,用来组合不同的Web3 provider,这些provider可能各自实现了Web3定义的部分功能,所以也被称为SubProvider。比如,你可以只使用Web3的filter功能,如下:
const ProviderEngine = require('web3-provider-engine')
const FilterSubprovider = require('web3-provider-engine/subproviders/filters.js')
var engine = new ProviderEngine()
var web3 = new Web3(engine)
engine.addProvider(new FilterSubprovider())
上面的代码也说明了Web3 provider engine是所有SubProvider的入口。
HookedSubprovider
HookedSubprovider和FilterSubprovider在概念上类似,不过它通过使用eth_sendRawTransaction
模拟了eth_sendTransaction
调用,造成一种假象,我们好像调用的是sendTransaction函数,这样做的目的应该是为了兼容以前的代码。
代码解释
先去HDWalletProvider里一窥究竟,关于BIP39定义的Mnemonic不是本篇的重点,对于理解HDWalletProvider的运作影响也不大,所以忽略不讲。直接进入往Provider engine中添加SubProvider的代码逻辑里。
this.engine.addProvider(
new HookedSubprovider({
getAccounts: function(cb) {
cb(null, tmp_accounts);
},
getPrivateKey: function(address, cb) {
if (!tmp_wallets[address]) {
return cb("Account not found");
} else {
cb(null, tmp_wallets[address].getPrivateKey().toString("hex"));
}
},
signTransaction: function(txParams, cb) {
let pkey;
const from = txParams.from.toLowerCase();
if (tmp_wallets[from]) {
pkey = tmp_wallets[from].getPrivateKey();
} else {
cb("Account not found");
}
const tx = new Transaction(txParams);
tx.sign(pkey);
const rawTx = "0x" + tx.serialize().toString("hex");
cb(null, rawTx);
},
signMessage(message, cb) {...}
})
);
在实现HookedSubprovider时,我们看到了getPrivateKey
和signTransaction
函数,这两个函数存在的意义就是为了协调eth_sendRawTransaction
发送签名后交易数据。其中,signTransaction
函数中使用库ethereumjs-tx对交易参数做了签名处理。看上去疑问已经被澄清了,但是,在那之前我们还有几个问题需要弄清楚,第一点就是自定义的SubProvider是如何被调用到的?
Provider、SubProvider接口和SubProvider的调用时机
Provider接口定义很简单,只有一个sendAsync函数,顾名思义,以异步的方式发起JSON RPC调用。
export interface Provider {
sendAsync(payload: JSONRPCRequestPayload, callback: JSONRPCErrorCallback): void;
}
SubProvider接口则定义了一个handleRequest(...)
需要子类敷写。
SubProvider.prototype.handleRequest = function(payload, next, end) {
throw new Error('Subproviders should override `handleRequest`.')
}
在我们执行truffle deploy
的时候,整个调用链条大体像下面这样:
deployer.deploy(YourContract) -> new TruffleContract(args) -> new Web3.eth.Contract(args) -> this.eth.sendTransaction(options, cb) -> method.requestManager.sendAsync(payload, cb)
链条的终点回到了eth.sendTransaction,并且最终交给Provider的sendAsync
函数执行,此处的requestManager就是Provider接口的实例。
在Web3 provider engine(它是调用所有SubProvider的入口)的文件中,可以看到sendAsync
的实现如下:
Web3ProviderEngine.prototype.sendAsync = function(payload, cb){
const self = this
self._ready.await(function(){
if (Array.isArray(payload)) {
// handle batch
map(payload, self._handleAsync.bind(self), cb)
} else {
// handle single
self._handleAsync(payload, cb)
}
})
}
_handleAsync
这个函数又会按链式结构依次调用到所有SubProvider的handleRequest(payload, next, end)
函数,截取的代码片段如下:
var provider = self._providers[currentProvider]
provider.handleRequest(payload, next, end)
这样,我们就弄明白了SubProvider的调用时机。那么接下来的两个问题是交易什么时候被签名的?sendTransaction什么时候被替换成sendRawTransaction的?
顺其自然地,我们进到自定义的HookedSubprovider中,研究它的handleRequest
函数,其中有段switch...case
在利用方法名做函数调用的分配:
case 'eth_sendTransaction':
txParams = payload.params[0]
waterfall([
(cb) => self.validateTransaction(txParams, cb),
(cb) => self.processTransaction(txParams, cb),
], end)
return
我们再经由processTransaction
进入函数finalizeAndSubmitTx(...)
中,这个函数就是我们的答案,它的职责是签名交易并把交易发送到全网,这其中必然会涉及函数替换。
HookedWalletSubprovider.prototype.finalizeAndSubmitTx = function(txParams, cb) {
const self = this
// can only allow one tx to pass through this flow at a time
// so we can atomically consume a nonce
self.nonceLock.take(function(){
waterfall([
self.fillInTxExtras.bind(self, txParams),
self.signTransaction.bind(self),
self.publishTransaction.bind(self),
], function(err, txHash){
self.nonceLock.leave()
if (err) return cb(err)
cb(null, txHash)
})
})
}
不难看出,self.signTransaction.bind(self)
就是之前自定义在Truffle HDWallet Provider中的signTransaction
函数,交易签名的问题解决了。我们再查看一下publicTransaction
函数。
HookedWalletSubprovider.prototype.publishTransaction = function(rawTx, cb) {
const self = this
self.emitPayload({
method: 'eth_sendRawTransaction',
params: [rawTx],
}, function(err, res){
if (err) return cb(err)
cb(null, res.result)
})
}
eth_sendRawTransaction
出现了,这正是sendTransaction被替换成sendRawTransaction的证据。诚如之前分析的一样,这个HookedSubprovider利用eth_sendRawTransaction
模拟了sendTransaction的操作。至此,总算搞明白Truffle HDWallet Provider的运作机制.
小结
Truffle HDWallet Provider 给开发者提供一种能力,那就是在省去了自己搭建以太坊客户端的同时,也兼顾了私钥的安全性。
最后,我们看看Truffle完整的配置文件(truffle-config.js)大概是什么样子的,如下:
var HDWalletProvider = require("truffle-hdwallet-provider");
var mnemonic = "mountains supernatural bird ...";
module.exports = {
networks: {
mainnet: {
provider: function() {
return new HDWalletProvider(mnemonic, 'https://mainnet.infura.io/<infura-key>')
},
network_id: '1',
gas: 4500000,
gasPrice: 10000000000
}
}
}
总结
Truffle 这个开发环境在节约开发者时间的同时,也引入了很多理解上的障碍层。而提出假设,然后动手实验或者阅读源码是检验假设的最好方法。希望我们在高效开发DApp时,也不要忘记思考手中武器的构造,尝试解释它们。
网友评论