基于MetaMask 理解钱包工作原理

基于MetaMask 理解钱包工作原理
Photo by Ray Hennessy / Unsplash

MetaMask 是一款广受欢迎的以太坊钱包浏览器插件,它为用户提供了一种安全、便捷的方式来管理他们的数字资产并与去中心化应用程序(DApp)进行交互。本文将深入探讨 MetaMask 的源代码,分析其架构设计、关键模块和功能实现,以帮助读者更好地理解这个钱包是如何工作的,以及其背后所依赖的相关标准。

MetaMask 的架构概览

MetaMask 采用了一种模块化的架构设计,各个模块之间职责明确,相互协作,以实现钱包的各项功能。主要模块包括:

  1. background.js:负责处理后台任务,如事务签名、状态更新等。
  2. contentscript.js:负责与 Web3 网站进行交互,注入 MetaMask 提供的 Web3 实例。
  3. ui:包含用户界面相关的组件和逻辑,如界面渲染、用户输入处理等。
  4. lib:包含各种工具函数和辅助模块,如助记词管理、密钥派生等。
  5. app:整合各个模块,构建完整的 MetaMask 应用。

钱包创建流程

当用户首次安装 MetaMask 时,会触发钱包创建流程。这个流程主要包括以下步骤:

  1. 生成助记词:MetaMask 使用 bip39 库生成一组随机的助记词(通常为 12 个单词)。相关代码位于 lib/seed-phrase.js 文件中的 generateMnemonic 函数。
  2. 派生主密钥:根据助记词和可选的密码,MetaMask 使用 hdkey 库派生出一个主密钥。相关代码位于 lib/hd-keyring.js 文件中的 _deserializeVault 函数。
  3. 派生账户私钥:基于 BIP44 定义的路径规则,MetaMask 从主密钥派生出默认账户的私钥。路径为 m/44'/60'/0'/0/0。相关代码位于 lib/hd-keyring.js 文件中的 _pathFromIndex 函数。
  4. 加密存储:MetaMask 使用用户提供的密码对助记词和私钥进行加密,然后将加密后的数据存储在本地。相关代码位于 lib/keyring.js 文件中的 serialize 函数。

种子短语和私钥的安全存储

MetaMask 采用了多重安全措施来保护用户的种子短语和私钥,其中最关键的是加密存储。

加密种子短语和私钥

MetaMask 使用对称加密算法(如 AES)对种子短语和私钥进行加密,然后将加密后的密文存储在本地。加密密钥是从用户的密码中派生出来的。相关的代码位于 lib/keyring.js 文件中的 encrypt 函数:

async encrypt(password, object) {
  const salt = this._getSalt() || crypto.randomBytes(16);
  const key = await this._getKey(password, salt);
  
  const cipher = crypto.createCipheriv('aes-256-gcm', key, crypto.randomBytes(16));
  const ciphertext = Buffer.concat([cipher.update(JSON.stringify(object)), cipher.final()]);
  const tag = cipher.getAuthTag();
  
  return Buffer.concat([salt, ciphertext, tag]).toString('base64');
}

这个函数首先生成一个随机的盐值(salt),然后使用 _getKey 函数从用户密码和盐值派生出加密密钥。接着,它使用 AES-256-GCM 算法创建一个 cipher 对象,随机生成一个初始化向量(IV),并使用 cipher 对象加密种子短语或私钥的 JSON 字符串表示。最后,它将盐值、密文和认证标签(tag)连接起来,并转换为 Base64 编码的字符串。

解密种子短语和私钥

当用户解锁 MetaMask 时,需要使用用户的密码来派生出解密密钥,然后用其解密种子短语和私钥。相关的代码位于 lib/keyring.js 文件中的 decrypt 函数:

async decrypt(password, encryptedString) {
  const encryptedBuffer = Buffer.from(encryptedString, 'base64');
  const salt = encryptedBuffer.slice(0, 16);
  const ciphertext = encryptedBuffer.slice(16, -16);
  const tag = encryptedBuffer.slice(-16);
  
  const key = await this._getKey(password, salt);
  
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, crypto.randomBytes(16));
  decipher.setAuthTag(tag);
  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  
  return JSON.parse(plaintext.toString());
}

这个函数首先从 Base64 编码的字符串中提取出盐值、密文和认证标签。然后,它使用 _getKey 函数和盐值从用户密码派生出解密密钥。接着,它创建一个 decipher 对象,设置认证标签,并使用 decipher 对象解密密文。最后,它将解密后的明文转换为字符串,并解析为 JavaScript 对象。

DApp 交互和交易签名

MetaMask 通过注入一个自定义的 Web3 实例,使得 DApp 能够与以太坊网络进行交互。这个自定义的 Web3 实例在 contentscript.js 文件中定义,其结构如下:

const inpageProvider = new MetamaskInpageProvider(metamaskStream);
const web3 = new Web3(inpageProvider);
web3.setProvider = () => {
  console.error('MetaMask: setProvider() is deprecated');
};

当用户在 DApp 中发起一笔交易时,MetaMask 会拦截这个请求,并向用户显示一个确认页面,列出交易的详细信息,如接收地址、交易金额、Gas 费用等。

用户确认后,MetaMask 会使用相应账户的私钥对交易进行签名。签名过程遵循以太坊的交易签名标准(如 EIP-155),并使用 ethereumjs-tx 库进行实现。相关的代码位于 lib/eth-tx-manager.js 文件中的 addUnapprovedTransaction 函数:

addUnapprovedTransaction(txParams, cb) {
  const { from } = txParams;
  this._validateTxParams(txParams);
  
  const txMeta = {
    id: createId(),
    time: Date.now(),
    status: 'unapproved',
    metamaskNetworkId: this._getCurrentChainId(),
    txParams: { ...txParams, from },
  };
  
  this._txs.push(txMeta);
  this._addTxToHistory(txMeta);
  this._updateTxsInState();
  
  this._recomputeUnapprovedTxsEvent();

  cb && cb();
}

签名完成后,MetaMask 将签名后的交易发送到以太坊网络,并返回交易哈希给 DApp。

页面注入对象

MetaMask 通过在 DApp 页面的 JavaScript 上下文中注入一个名为 window.ethereum 的对象来实现与 DApp 的交互。这个对象提供了一组标准化的 API,允许 DApp 与以太坊网络进行通信,发送交易,签名消息等。

window.ethereum 对象的结构和行为在很大程度上遵循了 EIP-1193(Ethereum Provider JavaScript API)标准。这个标准定义了 Ethereum Provider 应该如何与 DApp 进行交互,以及应该提供哪些 API。

以下是 window.ethereum 对象的一些关键属性和方法:

  1. ethereum.isMetaMask: 一个布尔值,指示当前 Provider 是否是 MetaMask。
  2. ethereum.networkVersion: 一个字符串,表示当前连接的以太坊网络 ID。
  3. ethereum.selectedAddress: 一个字符串,表示用户当前选择的以太坊地址(账户)。
  4. ethereum.request(args): 一个方法,允许 DApp 发送请求到 Ethereum Provider。这是 EIP-1193 中定义的主要方法,它取代了旧的 ethereum.send() 方法。args 参数是一个对象,包含请求的详细信息,如方法名和参数。
  5. ethereum.on(eventName, callback): 一个方法,允许 DApp 监听 Ethereum Provider 触发的事件。常见的事件包括:
    • accountsChanged: 当用户切换账户时触发。
    • networkChanged: 当用户切换网络时触发。
    • chainChanged: 当用户切换链时触发(EIP-1193 中新增的事件)。

下面是一个简化的 window.ethereum 对象示例:

window.ethereum = {
  isMetaMask: true,
  networkVersion: '1',
  selectedAddress: '0x1234567890123456789012345678901234567890',
  
  request: async (args) => {
    // 处理请求,如发送交易,签名消息等
    // ...
  },
  
  on: (eventName, callback) => {
    // 注册事件监听器
    // ...
  },
  
  // 其他属性和方法
  // ...
};

Web3 实例拦截

MetaMask 会通过其注入的自定义 Web3 实例(基于window.ethereum 创建的)拦截这个请求。具体而言,首次加载时就把MetamaskInpageProvider 注入到了window.ethereum,以便在交易发送到以太坊网络之前对其进行处理。

contentscript.js 文件中,MetaMask 定义了一个名为 MetamaskInpageProvider 的类,这个类继承自 EventEmitter,并重写了 request 方法:

class MetamaskInpageProvider extends SafeEventEmitter {
  // ...

 async request(args) {
    // 验证请求参数
    // ...

    return new Promise((resolve, reject) => {
      this._rpcRequest({ method, params }, getRpcPromiseCallback(resolve, reject));
    });
  }
  sendAsync(payload, cb) {
    this._rpcRequest(payload, cb);
  }

  // ...
}

_rpcRequest 方法是处理所有 RPC 请求的核心方法,它会根据请求的方法名进行不同的处理。对于 eth_sendTransaction 方法(用于发送交易),MetaMask 会拦截这个请求,并执行以下步骤:

  1. 对交易参数进行验证和规范化处理。
  2. 向后台页面发送一个名为 addUnapprovedTransaction 的消息,将交易添加到未确认交易列表中。
  3. 在用户界面上显示一个确认页面,列出交易的详细信息,如接收地址、交易金额、Gas 费用等。
  4. 用户确认交易后,MetaMask 会使用相应账户的私钥对交易进行签名。
  5. 将签名后的交易发送到以太坊网络,并返回交易哈希给 DApp。

相关的代码片段如下(contentscript.js):

async _rpcRequest(payload, cb) {
  // ...

  if (payload.method === 'eth_sendTransaction') {
    const txMeta = await this._addUnapprovedTransactionAndGetMeta(
      payload.params[0],
    );

    this._showConfirmationDialog(txMeta, () => {
      // 用户确认交易后的处理逻辑
      // ...
    });

    // 返回交易哈希给 DApp
    cb(null, txMeta.hash);
  } else {
    // 处理其他 RPC 请求
    // ...
  }

  // ...
}

在后台页面中,addUnapprovedTransaction 方法(位于 lib/eth-tx-manager.js)会将交易添加到未确认交易列表中,并触发相应的事件和状态更新:

addUnapprovedTransaction(txParams, cb) {
  // ...

  const txMeta = {
    id: createId(),
    time: Date.now(),
    status: 'unapproved',
    metamaskNetworkId: this._getCurrentChainId(),
    txParams: { ...txParams, from },
  };

  this._txs.push(txMeta);
  this._addTxToHistory(txMeta);
  this._updateTxsInState();

  this._recomputeUnapprovedTxsEvent();

  // ...
}

Read more

Vue.js异步更新与nextTick机制深度解析(上篇)

Vue.js异步更新与nextTick机制深度解析(上篇)

本文目标 学完本文,你将能够: * 理解Vue.js为什么采用异步更新策略 * 掌握更新队列的设计思想和实现机制 * 深入理解Event Loop在Vue中的应用 * 了解nextTick的多种实现方式 系列导航 上一篇: Diff算法深度剖析 | 下一篇: Vue.js异步更新与nextTick机制(下篇) | 组件系统架构 引言:为什么DOM更新是异步的? 在Vue.js开发中,你可能遇到过这样的场景: // 场景1:连续修改数据 export default { data() { return { count: 0 } }, methods: { increment() { // 如果每次修改都立即更新DOM,会触发3次DOM更新 this.count++ // 触发一次? this.count++ // 触发一次? this.count++ // 触发一次? // 实际上:Vue只会触发一次DOM更新!

Vue.js组件系统架构深度解析

本文目标 学完本文,你将能够: * 理解Vue.js组件从创建到销毁的完整生命周期 * 掌握组件实例化和初始化的内部流程 * 深入理解父子组件通信的底层机制 * 学会实现完整的插槽系统(包括作用域插槽) * 掌握动态组件和异步组件的实现原理 * 应用组件级别的性能优化技巧 系列导航 上一篇: 异步更新与nextTick(下篇) | 下一篇: 状态管理模式 引言:组件是如何工作的? 在Vue.js开发中,我们每天都在使用组件。但你是否想过: // 当我们这样定义一个组件 const MyComponent = { data() { return { count: 0 } }, template: '<button @click="count++">{{ count }}</button>' } // 并使用它时 new Vue({ components: { MyComponent }, template:

Vue.js状态管理模式:构建可扩展的应用架构

本文目标 学完本文,你将能够: * 理解为什么大型应用需要状态管理 * 掌握Vuex的核心设计模式和实现原理 * 实现一个简化版的状态管理库 * 理解模块化和命名空间的设计思想 * 掌握大型应用的状态管理最佳实践 * 了解现代状态管理方案的演进 系列导航 上一篇: 组件系统架构 | 下一篇: 性能优化实践 1. 为什么需要状态管理? 1.1 组件通信的困境 在大型Vue.js应用中,组件间的通信会变得异常复杂: // 问题场景:多层级组件的状态共享 // GrandParent.vue <template> <Parent :user="user" @update-user="updateUser" /> </template> // Parent.vue <template> <Child

Vue.js依赖收集与追踪机制深度剖析

本文目标 学完本文,你将能够: * 理解Vue.js如何精确知道哪些组件需要更新 * 掌握Dep、Watcher、Observer三大核心类的协作机制 * 深入理解依赖收集的时机和完整过程 * 能够手写一个完整的依赖收集系统 * 解决实际开发中的依赖追踪问题 系列导航 上一篇: 响应式系统核心原理 | 下一篇: Virtual DOM实现详解 引言:为什么Vue知道哪些组件需要更新? 在使用Vue.js时,你是否想过这样一个问题:当我们修改一个数据时,Vue是如何精确地知道哪些组件用到了这个数据,并只更新这些组件的? // 假设有这样的场景 const app = new Vue({ data: { user: { name: 'John', age: 25 } } }); // 组件A只用到了user.name // 组件B只用到了user.age // 组件C同时用到了name和age // 当我们修改user.name时 app.user.name = 'Jane&