Truffle Provider 构造及其解释

Truffle default web3 HttpProvider

定义

Truffle[1] 是以太坊上智能合约的开发环境,类似Java语境下的Maven或者Gradle工具,但是内涵更加丰富,除了编译链接,它还兼顾了智能合约的部署,部署需要适配多样的目标环境,例如本地Ganache模拟的开发网络、以太坊测试网络(Ropsten Rinkeby or Kovan Net)、以太坊主网(Main Net)。Truffle 为此提供了provider配置选项,默认使用Web3JS中定义的Web3.providers.HttpProvider,它会使用hostoption选项构造出目标地址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_lockAccountRPC命令。

然后,我们开始执行部署命令truffle deploy,不出所料,部署失败,抛出的错误是"Migrations" -- Returned error: signer account is locked。而在另一边,Ganache控制台停在了eth_sendTransactionRPC命令那一步。

小结

从上面两个试验的结果得出结论:在默认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是如何做到的呢?我们先列出几个关键概念,然后用代码解释。

关键概念

  1. Web3 provider engine
  2. 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时,我们看到了getPrivateKeysignTransaction函数,这两个函数存在的意义就是为了协调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时,也不要忘记思考手中武器的构造,尝试解释它们。


  1. Truffle Framework
  2. Ethereum web3ethsendtransaction
  3. Etheurem web3ethsendrawtransaction
  4. Ethereum JSON-RPC
  5. Ethereum personal_lockAccount
  6. Ethereum service provider - Infura
  7. Truffle HDWallet Provider
  8. Web3
  9. MetaMask

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券