Solidity学习之代理合约

什么是代理合约

代理合约针对的是链上合约一经部署无法修改的问题,通过增加一层代理合约,就可以在不修改代理合约代码和地址的前提下,对实际执行的逻辑进行调整,满足了合约升级的需要。

实现逻辑

代理模式将状态变量存储在代理合约中,而逻辑执行在逻辑合约中。

通过delegateCall调用,执行逻辑合约的同时,改变的是代理合约中的状态变量,并且将执行结果返回给caller

具体实现

代理合约

contract Proxy {
    address public implementation; // 逻辑合约地址

    /**
     * @dev 初始化逻辑合约地址
     */
    constructor(address implementation_){
        implementation = implementation_;
    }
    
/**
* @dev 回调函数,将本合约的调用委托给 `implementation` 合约
* 通过assembly,让回调函数也能有返回值
*/
fallback() external payable {
    address _implementation = implementation;
    assembly {
        // 将msg.data拷贝到内存里
        // calldatacopy操作码的参数: 内存起始位置,calldata起始位置,calldata长度
        calldatacopy(0, 0, calldatasize())

        // 利用delegatecall调用implementation合约
        // delegatecall操作码的参数:gas, 目标合约地址,input mem起始位置,input mem长度,output area mem起始位置,output area mem长度
        // output area起始位置和长度位置,所以设为0
        // delegatecall成功返回1,失败返回0
        let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

        // 将return data拷贝到内存
        // returndata操作码的参数:内存起始位置,returndata起始位置,returndata长度
        returndatacopy(0, 0, returndatasize())

        switch result
        // 如果delegate call失败,revert
        case 0 {
            revert(0, returndatasize())
        }
        // 如果delegate call成功,返回mem起始位置为0,长度为returndatasize()的数据(格式为bytes)
        default {
            return(0, returndatasize())
        }
    }
}

在代理合约中指定了一个implementation,即逻辑合约的地址,并且只实现了一个fallback()方法,所有发送给代理合约的调用都会通过fallback()发到逻辑合约上,代理合约只起到一个中转的作用。

代理合约的重点和难点就是fallback()的实现,代码中用assembly标明使用的是内联汇编的操作码,可以直接执行一些内存操作,而不是经过solidity的高级语法。

此处用到了5个内联汇编的方法:

  • calldatacopy(destOffset,dataOffset, size)
  • calldatasize()
  • returndatacopy(destOffset,dataOffset,size)
  • returndatasize()
  • delegatecall(gas,address,destOffset,size, destOffset,size)

其中两个size方法比较好理解,也就是拿到数据的长度。

而两个call方法做了类似的事情,即从某个数据来源处将数据复制到合约的memory内存中,比如calldatacopy()就会从calldata获取数据,而returndatacopy()则从returndata buffer里面获取,拿到的是上一次 call / delegatecall / staticcall 的返回值。

至于参数,第一个destOffset指向了内存的某个位置,是用来存放新数据的起始位置点,而dataOffset则是在获取数据时候的偏移值,比如calldatacopydataOffset为1,那么就是从calldata的第二个字节开始取值,一共向后取size个字节的数据。

最后就是delegatecall(),此处和高级语法中不同,参数用的都是内存值。首先指定了gas和调用的对象address,然后用四个参数标明calldatareturndata的存放位置,与copy的时候是一致的。

此时可以总结一下fallback()做的事情:

  1. calldata复制到内存中起始为0的位置
  2. delegatecall调用address的方法,calldata来自于内存,而对于returndata,最后一个参数为0,表示返回值的处理范围为0,也就是不做处理,这是因为把处理留到了后面。
  3. 将返回值从returndata buffer中复制到内存中
  4. 根据调用成功与否,选择用revertreturn返回returndata

重点

  • 不在delegatecall中读取返回值是因为此时的返回值长度是不确定的,所以放到后面用returndatacopy去处理。
  • delegatecall处理返回值与否不会影响到returndata buffer中的数据,在下一次call调用之前,buffer中的返回值会一直存在。
  • 写法中returndatacopy()的时候实际上覆盖了原来的calldata,因为写入的起点都是0,但因为calldatadelegatecall之后就没用了,所以这是安全的

逻辑合约

此处实现一个简单的逻辑合约即可,和正常的合约没什么区别。

contract Logic {
    address public implementation; // 与Proxy保持一致,防止插槽冲突
    uint public x = 99; 
    event CallSuccess(); // 调用成功事件

    // 这个函数会释放CallSuccess事件并返回一个uint。
    // 函数selector: 0xd09de08a
    function increment() external returns(uint) {
        emit CallSuccess();
        return x + 1;
    }
}

逻辑合约唯一要注意的点就是必须定义代理合约中的成员变量,这是因为delegatecall的时候是根据逻辑合约的成员变量位置而去修改代理合约中相同位置的状态量

比如此处如果在函数中修改了implementation的值,因为implementtion位于slot[0]的位置,那么代理合约收到的修改指令同样是修改slot[0]的值,如果逻辑合约中implementation不是第一个定义的,而是第二个,位于slot[1]的位置,一旦发生修改,即使代理合约中并不存在slot[1]的变量,也依然会在内存位置里覆盖写入,造成不可预知的问题。

调用合约

contract Caller{
    address public proxy; // 代理合约地址

    constructor(address proxy_){
        proxy = proxy_;
    }

    // 通过代理合约调用increment()函数
    function increment() external returns(uint) {
        ( , bytes memory data) = proxy.call(abi.encodeWithSignature("increment()"));
        return abi.decode(data,(uint));
    }
}

在调用合约中,方法签名和参数都是根据逻辑合约来的,代理合约只起到中转的作用。

执行结果

最后返回的结果是1,这是因为increment去读取代理合约中的x值,位于slot[1],而代理合约在这个位置没有定义变量,为0,所以此时返回0+1 =1

可升级合约

以代理合约为基础,在其中增加一个upgrade()函数,就可以实现逻辑合约的升级。

    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }

通过require控制升级函数只能由admin调用,调用upgrade()后逻辑合约就会变成传入的地址。

选择器冲突

在solidity中,函数选择器是函数签名的哈希的前4个字节,因为字节数少,所以会出现不同函数的函数选择器相同的情况,被称为选择器冲突

正常情况下,如果在合约中出现选择器冲突,会在编译的时候被编译器发现并报错,无法通过编译。

但是在可升级合约的场景中,因为代理合约实现了upgrade()方法,存在一种可能性,就是逻辑合约中有某个函数的函数选择器与upgrage()方法相同。调用这个方法时,data先传到了代理合约中,被认为是一次调用upgrade()的请求,从而出现了错误的调用。

严重的情况下,因为传入的参数不确定,可能会将逻辑合约指向黑洞地址。

为了解决这一问题,有透明代理和UUPS两种方法。

透明代理

透明代理的实现非常简单,通过权限隔离的方式,在upgrade()fallback()方法中限制调用方的地址:upgrade()只能由管理员调用,而fallback()则必须由非管理员的地址调用。

这样一来就可以避免因为调用相同函数选择器而错误调用升级函数的情况,保证了升级的安全。

   fallback() external payable {
        require(msg.sender != admin);
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }

    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        if (msg.sender != admin) revert();
        implementation = newImplementation;
    }

但逻辑函数中依然有可能存在与upgrade()函数选择器相同的方法,并且这个方法永远无法被调用。但是这种情况下并不会影响合约的安全所以可以忽视,只是在写合约时应当手动检查避免无效函数出现。

UUPS

UUPS是universal upgradeable proxy standard的缩写,指的是通用可升级代理标准。这一标准规定了升级合约要写在逻辑合约中,利用编译检查来避免选择器冲突的问题。

因为delegatecall的特性,所以在逻辑合约中依然可以判断msg的来源是否为管理员,并且修改代理合约中implementation的值,如下:

    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }

二者的优劣势

UUPS

  • 代理合约更小(只负责 delegatecall)
  • ✅ 升级逻辑灵活:每个实现合约可以定义自己的升级策略(如版本检测、权限校验)
  • ✅ 省 gas:因为 upgrade 逻辑不在 Proxy 中
  • ⚠️ 如果你实现的 upgradeTo() 没写权限控制,会被任意升级(重大安全风险)
  • ⚠️ 升级逻辑自己写的不好,容易让合约变砖(如升级自己指向了一个非实现合约)
  • ⚠️ OpenZeppelin 要求必须继承 UUPSUpgradeable 并带 onlyProxy 修饰函数,防止错误调用

透明代理

  • ✅ 最稳定、最传统的升级方式
  • ✅ upgrade 权限由 Proxy 管理,不容易犯错
  • ✅ 社区使用广泛,工具链支持成熟
  • ❌ Proxy 更复杂、部署成本高
  • ❌ 所有调用都要通过 proxy,admin 地址不能调用逻辑函数(会被拒绝)
  • ❌ 无法动态调整 upgrade 权限策略(都是在 Proxy 里写死的)
posted @ 2025-08-07 13:37  Felix07  阅读(45)  评论(0)    收藏  举报