ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Euler 模块代理模式(Module Proxy Mode)深度解析:可独立升级的模块化可升级合约架构

Euler 模块代理模式(Module Proxy Mode)深度解析:可独立升级的模块化可升级合约架构
  • 示例工程
  • 区块链

【免费下载链接】Dapp-Learning

Dapp learning project for developers at all stages. Becoming and cultivating sovereign individuals. Nonprofit organization.

项目地址:https://gitcode.com/gh_mirrors/da/Dapp-Learning
点击查看免费下载

模块代理模式是 Euler 借贷协议在合约工程实现上的一大亮点:它在传统单代理(Proxy)可升级架构的基础上,将协议拆分为"主入口代理 + 多个功能模块",使每个模块都能独立安装与升级,而协议核心状态始终集中存储在主入口合约上。本文以仓库文档 defi/Euler/ModuleProxyMode.md 为主体骨架,结合仓库内 Proxy.md、BaseLogic.md、contracts/README.md 等源码级分析文档,完整梳理模块代理的整体架构、部署/安装/升级流程、调用链路与底层 assembly 代码逻辑,帮助读者掌握这一可复用的模块化可升级架构设计。

背景:从传统 Proxy 到模块化 Proxy

传统的代理(Proxy)模式只有一个入口代理合约与一个逻辑(Implementation)合约:所有调用先经过 Proxy 的 fallback,再由delegatecall转发到唯一的逻辑合约。这种模式最大的问题是不可模块化——无论协议中哪一个功能需要升级,都必须整体替换逻辑合约,升级范围大、风险面广,也难以对单个功能进行版本管理。

Euler 对此进行了改进:不再把协议当作一个整体,而是拆分成多个可独立管理的模块,每个模块都可以单独升级。根据仓库文档 contracts/README.md,Euler 一共有 9 个模块:

模块职责
DToken债务凭证
EToken抵押凭证
Exec执行合约,可批量执行操作
Governance治理模块
Installer模块安装器,涉及模块的设置和权限
Liquidation清算模块
Markets资产市场,存储资产在借贷市场中的相关参数(如BorrowFactor、CollateralFactor)
RiskManager风险管理模块
Swap交易模块(用户 EToken 之间的交易,不是 underlying)

相较于常规 Proxy 模式,这种模块化升级模式具备以下特点(同样出自 contracts/README.md):

  1. 可以将合约拆分成各个模块单独管理版本,例如仅升级其中一个模块而无需升级所有逻辑合约;
  2. 协议所有 Storage 状态变量都集中在入口合约Euler.sol上,而非分布于不同模块的 Proxy 合约上,状态管理更加方便;
  3. 部分日志可以由模块 Proxy 发出,而不是统一由入口合约发出(详见后文emitViaProxy_Transfer与emitViaProxy_Approval);
  4. 每个模块都有moduleId和moduleGitCommits作为特征:moduleId是每种模块的固定编号,moduleGitCommits是版本标记,便于比对。

整体架构:MainProxy + Installer + 功能模块

模块代理的整体架构由三层角色组成:

  • 主入口代理合约(MainProxy,即Euler.sol):协议所有状态的存储地,也是模块调度的中枢,负责把调用路由到对应模块的逻辑合约;
  • Installer 模块:模块安装器,负责新模块的安装与权限管理;
  • 功能模块:每个功能模块都由两个合约构成——Implementation 合约(执行逻辑)与ModuleProxy 合约(代理)。

也就是说,每个模块都有一个 Implementation 合约(存放逻辑代码)和一个 ModuleProxy 合约(作为用户入口与状态桥接)。而所有这些模块 Proxy 都由 MainProxy 通过模板创建并登记,ModuleProxy 与 Implementation 之间通过映射(mapping)绑定。

模块代理模式的调用过程非常"绕"(见后文调用流程),但这样做有一个很大的优势:由于最终是由 MainProxy 使用delegatecall调用具体逻辑,Euler 合约的主要状态都存储在主入口代理合约 MainProxy 上,因此模块的安装/升级并不会影响协议的状态。

合约部署流程

原文档给出了主入口合约的部署流程,共三步:

  1. 部署 Installer 模块的 Implementation(执行逻辑合约);
  2. 传入步骤 1 的地址作为入参,部署 MainProxy 主入口合约;
  3. MainProxy 构造函数通过 ModuleProxy 模板创建一个 Installer 模块的 Proxy 合约,并将两者地址绑定(mapping)。

仓库 Proxy.md 中给出了Euler.sol构造函数的对应源码片段,可以印证上述流程:

// src/Euler.sol contract Euler is Base { constructor(address admin, address installerModule) { ... moduleLookup[MODULEID__INSTALLER] = installerModule; address installerProxy = _createProxy(MODULEID__INSTALLER); trustedSenders[installerProxy].moduleImpl = installerModule; } ... }

这里的关键动作有两个:

  • moduleLookup[MODULEID__INSTALLER] = installerModule;:把 Installer 模块的逻辑合约地址注册到模块查找表moduleLookup中,建立"模块编号 → 逻辑实现"的映射;
  • address installerProxy = _createProxy(MODULEID__INSTALLER);:调用_createProxy创建 Installer 模块的 ModuleProxy 代理合约;
  • trustedSenders[installerProxy].moduleImpl = installerModule;:在trustedSenders中把代理地址与逻辑实现绑定。

其中_createProxy是模块代理创建的核心函数,定义于 BaseLogic.md,其源码为:

function _createProxy(uint proxyModuleId) internal returns (address) { require(proxyModuleId != 0, "e/create-proxy/invalid-module"); require(proxyModuleId <= MAX_EXTERNAL_MODULEID, "e/create-proxy/internal-module"); // If we've already created a proxy for a single-proxy module, just return it: if (proxyLookup[proxyModuleId] != address(0)) return proxyLookup[proxyModuleId]; // Otherwise create a proxy: address proxyAddr = address(new Proxy()); if (proxyModuleId <= MAX_EXTERNAL_SINGLE_PROXY_MODULEID) proxyLookup[proxyModuleId] = proxyAddr; trustedSenders[proxyAddr] = TrustedSenderInfo({ moduleId: uint32(proxyModuleId), moduleImpl: address(0) }); emit ProxyCreated(proxyAddr, proxyModuleId); return proxyAddr; }

从源码结构可以提炼出几个实现细节:

  • 模块编号校验:proxyModuleId != 0防止非法编号,proxyModuleId <= MAX_EXTERNAL_MODULEID则区分外部模块与内部模块(内部模块不可直接创建外部代理);
  • 单代理模块复用:proxyLookup缓存了"单代理模块"已创建的代理地址,若已创建则直接返回,避免重复创建(MAX_EXTERNAL_SINGLE_PROXY_MODULEID界定哪些模块是单代理模块);
  • 代理创建方式:address(new Proxy())直接通过new关键字部署一个全新的Proxy模板实例;
  • 信任发送方登记:trustedSenders[proxyAddr] = TrustedSenderInfo({...})记录该代理归属的模块编号,这是后续鉴权(判断调用来源是否可信)的基础;
  • 创建事件:发出ProxyCreated(proxyAddr, proxyModuleId)事件,便于链上索引与监控。

在后续激活新资产市场时,这一机制同样被复用。例如 Markets.md 中doActivateMarket会为每种资产创建 EToken/DToken 两个模块代理:

address childEToken = params.config.eTokenAddress = _createProxy(MODULEID__ETOKEN); address childDToken = _createProxy(MODULEID__DTOKEN);

功能模块安装/升级流程

当协议需要新增或升级某个功能模块时,走的是 Installer 模块的安装流程,原文档给出四步:

  1. 部署新模块的 Implementation 合约(可以一次部署多个模块);
  2. 将新模块的地址数组传入 Installer Proxy,调用InstallModules()函数;
  3. 函数内部遍历这些模块,并为每个模块创建对应的 ModuleProxy 合约;
  4. 将每个新模块的 Implementation 和 ModuleProxy 绑定(mapping),完成新模块安装。

结合_createProxy的源码可以看出第 3、4 步的具体语义:InstallModules()内部对地址数组逐个调用_createProxy(moduleId)生成 ModuleProxy,随后通过trustedSenders[proxyAddr].moduleImpl = implementation完成逻辑合约与代理的绑定。

这样的设计带来的直接好处是:升级某个模块时只需部署新的 Implementation,并通过 Installer 更新绑定关系即可,其他模块的代理与逻辑完全不受影响;由于协议状态都保存在 MainProxy 上,模块替换过程也不会触碰任何用户资金状态。

用户调用模块功能接口流程

用户在 Euler 中调用某个模块的功能(如存款、借款)时,实际走的是"用户 → 模块 Proxy → MainProxy.dispatch → 模块 Implementation"的多级转发链路,原文档概括为三步:

  1. 向模块的 ModuleProxy 发起调用(注意:不是直接调用 MainProxy);
  2. ModuleProxy 会使用call操作码来调用 MainProxy 的dispatch函数;
  3. dispatch函数会使用delegatecall操作码来调用模块的 Implementation 执行逻辑合约。

仓库 hack/EulerAttack.md 中记录的eToken.deposit业务流程图,正是这条链路的直观体现:

sender -> proxy(eToken): call deposit proxy(eToken) -> Euler: call dispatch Euler -> Impl(eToken): delegatecall deposit sender --> Euler: Dai transfer amountA Impl(eToken) --> sender: mint eDai amountB ... Euler -> proxy(eToken): call emitEvent

这条链路同样适用于withdraw、mint、borrow、repay、transferFrom等所有模块操作,区别仅在于入口是哪个模块的 Proxy(例如借款走proxy(dToken))。链路虽长,却是有意为之的架构权衡:

  • 用户面对的是模块专属的代理地址(如 eToken Proxy、dToken Proxy),语义清晰;
  • 中间层MainProxy 承担路由与状态载体的角色;
  • 最内层模块 Implementation 只负责逻辑执行,其代码运行在 MainProxy 的存储上下文之上。

核心代码逻辑深度解析

ModuleProxy 的 fallback:calldata 重组与转发

ModuleProxy 合约本身没有业务函数,所有调用都会进入fallback(这也是常见 Proxy 的原理)。关键在于其 fallback 对 calldata 做了重组,原文档给出了重组后的数据布局:

dispatch selector + calldata + caller address

然后使用call操作码调用 MainProxy。仓库 Proxy.md 中给出了完整的 fallback 源码,其中非创建者分支如下:

} else { assembly { mstore(0, 0xe9c4a3ac00000000000000000000000000000000000000000000000000000000) // dispatch() selector calldatacopy(4, 0, calldatasize()) mstore(add(4, calldatasize()), shl(96, caller())) let result := call(gas(), creator_, 0, 0, add(24, calldatasize()), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } }

逐行解读这段 assembly:

  1. mstore(0, 0xe9c4a3ac...):在内存起始位置写入dispatch()的函数选择器(selector 为0xe9c4a3ac);
  2. calldatacopy(4, 0, calldatasize()):把原始 calldata 拷贝到内存偏移 4 处,紧跟在 dispatch selector 之后;
  3. mstore(add(4, calldatasize()), shl(96, caller())):在原始 calldata 之后追加 20 字节的调用者地址(caller()),通过左移 96 位对齐到高 20 字节;
  4. call(gas(), creator_, 0, 0, add(24, calldatasize()), 0, 0):以重组后的 calldata(长度24 + calldatasize)调用creator_(即 MainProxy)。

这里有两个关键点:

  • creator_就是 MainProxy。由于所有模块的 Proxy 都是由 MainProxy 创建,而Proxy的构造函数为constructor() { creator = msg.sender; }(见 Proxy.md),所以创建者即 MainProxy 地址;
  • 调用链中附加了caller()原始调用者地址。这是为了在下一层dispatch中恢复真实的用户地址——因为经 ModuleProxy 转发后,MainProxy 看到的msg.sender是 ModuleProxy 而非用户,必须显式地把用户地址附加在 calldata 尾部传递下去。

MainProxy 的 dispatch:长度校验与路由执行

接下来进入 MainProxy 的dispatch函数。它首先会检查 calldata 长度:因为从 ModuleProxy 发送过来的调用都经过重组,合法的调用长度最小为 28 字节,其构成如下(原文档给出):

4 dispatch selector + 4 some function selector + (payload) + 20 caller address

即:4 字节的dispatch函数选择器、4 字节的模块函数选择器、可变的 payload、以及 20 字节的调用者地址,最小长度4 + 4 + 20 = 28字节。长度校验可以过滤掉那些未按模块代理协议组装的不合法调用。

通过长度校验后,dispatch会使用delegatecall操作码调用模块的执行逻辑合约,把控制权交给对应模块的 Implementation。这里与 ModuleProxy 形成鲜明对比:

注意:ModuleProxy 中使用call调用,而 MainProxy 中使用delegatecall调用,所以函数运行逻辑在模块的 Implementation 中,而所使用的状态在 MainProxy 中。

这个"一段call、一段delegatecall"的组合是模块代理模式的精髓:

  • ModuleProxy → MainProxy 用call:创建独立的调用上下文,MainProxy 的dispatch代码在自己的存储上下文中运行;
  • MainProxy → Implementation 用delegatecall:模块逻辑代码在MainProxy 的存储上下文中执行,读写的是 MainProxy 上的状态,从而实现"状态集中、逻辑分散"。

日志事件的分模块记录

模块代理模式中另一个精彩的设计是:event log 记录在模块的 Proxy 中,而不是堆叠在 MainProxy 上。

原文档指出:在 fallback 中有一个条件语句,当使用模块中类似emitViaProxy_Transfer的函数发出 event log 时,日志会被分门别类地记录在不同模块的 Proxy 中,而不会全部堆叠在 MainProxy 中。

对应到 Proxy.md 的源码,fallback 的第一个分支正是处理这种场景——当msg.sender == creator_(即调用来自 MainProxy 内部模块)时,直接解析 calldata 并以模块 Proxy 自身的地址发出日志:

if (msg.sender == creator_) { assembly { mstore(0, 0) calldatacopy(31, 0, calldatasize()) switch mload(0) // numTopics case 0 { log0(32, sub(calldatasize(), 1)) } case 1 { log1(64, sub(calldatasize(), 33), mload(32)) } case 2 { log2(96, sub(calldatasize(), 65), mload(32), mload(64)) } case 3 { log3(128, sub(calldatasize(), 97), mload(32), mload(64), mload(96)) } case 4 { log4(160, sub(calldatasize(), 129), mload(32), mload(64), mload(96), mload(128)) } default { revert(0, 0) } return(0, 0) } }

该分支先读取 calldata 首部的numTopics(日志主题数量),再根据 0~4 个 topic 的情况分别调用log0~log4操作码在当前 Proxy 合约地址上写出日志。这解决了可升级合约场景中的一个典型问题:

  • 若模块 Implementation 直接emit事件,由于delegatecall的特性,日志的address字段会是 MainProxy 地址,导致所有模块的日志全部堆叠在 MainProxy 上,难以区分归属;
  • Euler 通过emitViaProxy_Transfer、emitViaProxy_Approval这类辅助函数,把日志数据打包成 calldata 再"回拨"给对应的 ModuleProxy,让日志以各模块自己的地址发出,从而将不同模块(如每个资产的 EToken/DToken)的事件分门别类地归档在各自 Proxy 下,便于链上索引与应用层解析。

模块化代理设计的优势与权衡

综合原文档与仓库源码,可以总结出 Euler 模块代理模式的核心收益:

  • 模块独立升级:9 个模块各司其职,单个模块的 Implementation 可独立替换,无需整体迁移逻辑合约,升级风险被有效收敛(见 contracts/README.md);
  • 状态集中管理:所有协议状态都在 MainProxy(Euler.sol)上,模块安装/升级不影响既有状态,trustedSenders、moduleLookup、proxyLookup等映射让状态与模块的对应关系清晰可查;
  • 日志按模块归档:emitViaProxy_*机制让事件日志归属到各模块 Proxy,而不是在 MainProxy 上混成一团;
  • 版本特征可追踪:moduleId(模块固定编号)与moduleGitCommits(版本标记)共同构成模块的版本指纹,便于审计比对。

与此同时,从代码结构看,这一设计也有明确的代价:

  • 调用链路长:用户请求需要经过"ModuleProxy → MainProxy.dispatch → Implementation"两跳转发,相比单层代理多了一次外部调用;
  • calldata 重组带来额外 gas:每次调用都要在 assembly 中拼接 selector、calldata 与 caller 地址,且日志转发需要额外的内联汇编处理;
  • 实现复杂度高:trustedSenders鉴权、长度校验、日志 topic 分支等细节都需要在底层 assembly 中小心处理,这对工程实现与安全审计提出了更高要求。

延伸阅读

  • 模块代理模式文档:本文主体来源,含架构与流程图示说明;
  • Proxy 源码级分析:Proxy合约构造函数与完整 fallback assembly 源码;
  • BaseLogic 源码级分析:_createProxy、trustedSenders、proxyLookup等模块代理底层实现;
  • Euler 合约指南:9 个模块清单与模块化升级特点总结;
  • Markets 源码级分析:doActivateMarket中复用_createProxy创建 EToken/DToken 代理的实例;
  • Euler 攻击复盘:以eToken.deposit等操作的 sequence 图直观展示模块代理的完整调用链;
  • Euler 协议导读:Euler 借贷协议的整体原理、流动性计算与清算机制背景。
  • 示例工程
  • 区块链

【免费下载链接】Dapp-Learning

Dapp learning project for developers at all stages. Becoming and cultivating sovereign individuals. Nonprofit organization.

项目地址:https://gitcode.com/gh_mirrors/da/Dapp-Learning
点击查看免费下载
上一篇:Mole Mac 深度清理使用指南:5 分钟拿回好几个 GB 磁盘空间
下一篇:Responsive Elements高级配置指南:自定义断点、间隔与性能优化

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进