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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,014评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,796评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,484评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,830评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,946评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,114评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,182评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,927评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,369评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,678评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,832评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,533评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,166评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,885评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,128评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,659评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,738评论 2 351

推荐阅读更多精彩内容