xyyme.eth

Posted on Feb 07, 2023Read on Mirror.xyz

Sudoswap 中对于 EIP-1167 的应用

最近在读 Sudoswap 的合约代码,发现其中应用了 EIP-1167 的玩法,有些写法感觉很有意思,因此想特地写篇文章来记录分享一下。对于 EIP-1167 还不太了解的朋友可以看看我之前写的这篇文章

工厂合约

在 Sudoswap 代码中,LSSVMPairFactory 合约是 Pair 的工厂合约,其中可以通过 createPairETH 创建 ETH 的交易对,通过 createPairERC20 创建 ERC20 的交易对。在创建交易对的过程中,调用 LSSVMPairCloner 库合约,其中应用了 EIP-1167 协议。

我们来看看库合约中创建 Pair 的方法:

function cloneETHPair(
    address implementation,
    ILSSVMPairFactoryLike factory,
    ICurve bondingCurve,
    IERC721 nft,
    uint8 poolType
) internal returns (address instance) {
    assembly {
        let ptr := mload(0x40)

        mstore(
            ptr,
            hex"60_72_3d_81_60_09_3d_39_f3_3d_3d_3d_3d_36_3d_3d_37_60_3d_60_35_36_39_36_60_3d_01_3d_73_00_00_00"
        )
        mstore(add(ptr, 0x1d), shl(0x60, implementation))

        mstore(
            add(ptr, 0x31),
            hex"5a_f4_3d_3d_93_80_3e_60_33_57_fd_5b_f3_00_00_00_00_00_00_00_00_00_00_00_00_00_00_00_00_00_00_00"
        )

        mstore(add(ptr, 0x3e), shl(0x60, factory))
        mstore(add(ptr, 0x52), shl(0x60, bondingCurve))
        mstore(add(ptr, 0x66), shl(0x60, nft))
        mstore8(add(ptr, 0x7a), poolType)

        instance := create(0, ptr, 0x7b)
    }
}

其中前三个 mstore 与我们之前的文章介绍的 EIP-1167 的写法类似,但是后面又多了几行 mstore 以及 mstore8,这些都是什么意思呢?

字节码分析

我们先来看看前三个 mstore,这里的具体字节码与之前我们介绍的 1167 写法有些出入,这三行组合出的字节码是(长度为 62 字节):

60723d8160093d39f33d3d3d3d363d3d37603d6035363936603d013d73bebebebebebebebebebebebebebebebebebebebe5af43d3d93803e603357fd5bf3

分割一下,得到:

1 -> 60723d8160093d39f3
2 -> 3d3d3d3d363d3d37603d6035363936603d013d73bebebebebebebebebebebebebebebebebebebebe5af43d3d93803e603357fd5bf3

其中第一部分的九个字节,反编译得到的结果是:

contract Contract {
    function main() {
        memory[returndata.length:returndata.length + 0x72] = code[0x09:0x7b];
        return memory[returndata.length:returndata.length + 0x72];
    }
}

可以看到,合约初始化时将字节码中从 9 字节到 123(0x7b) 字节的数据返回到 EVM 中,但是我们前面的字节码只有 62 字节,后面还有 61 字节都是什么呢?

此时我们再回看前面的 cloneETHPair 代码,没错,后面的 61 字节就是来自于其最后的几个 mstore,其中 factorybondingCurve 以及 nft 都是地址类型,分别占据 20 字节,poolType 是 bool 类型,占据 1 个字节。

我们再将上面字节码的第二部分进行反编译:

contract Contract {
    function main() {
        var temp0 = returndata.length;
        var var1 = returndata.length;
        var temp1 = msg.data.length;
        memory[returndata.length:returndata.length + temp1] = msg.data[returndata.length:returndata.length + temp1];
        memory[msg.data.length:msg.data.length + 0x3d] = code[0x35:0x72];
        var temp2;
        temp2, memory[returndata.length:returndata.length + returndata.length] = 
            address(0xbebebebebebebebebebebebebebebebebebebebe)
            .delegatecall.gas(msg.gas)(memory[returndata.length:returndata.length + msg.data.length + 0x3d]);
        var temp3 = returndata.length;
        var var0 = returndata.length;
        memory[temp0:temp0 + temp3] = returndata[temp0:temp0 + temp3];
    
        if (temp2) { return memory[var1:var1 + var0]; }
        else { revert(memory[var1:var1 + var0]); }
    }
}

从上面代码中可以看到,代理合约在调用逻辑合约的时候,总会在最后加上 61 (0x3d)字节的 calldata,正是前面的 factorybondingCurvenft 以及 poolType。那么也就是说,任何通过代理合约转发到逻辑合约的调用,最后都会加上这段 calldata,意味着在逻辑合约的方法中,总是可以获取到这段 calldata。

获取数据

我们来看看 Pair 合约中是如何获取这几个字段值的,以 bondingCurve() 为例:

function bondingCurve() public pure returns (ICurve _bondingCurve) {
    // 这里 ETH Pair 是 61,ERC20 Pair 是 81
    uint256 paramsLength = _immutableParamsLength();
    assembly {
        _bondingCurve := shr(
            0x60,
            calldataload(add(sub(calldatasize(), paramsLength), 20))
        )
    }
}

_immutableParamsLength() 返回常量,ETH Pair 返回 61,ERC20 Pair 返回 81。我们前面参考的方法是 cloneETHPair,因此我们这里默认其值为 61。

接下来的一段汇编代码,首先最里面的 calldatasize() 获取整个 calldata 的长度,其中是包含后面 61 字节的,然后用 calldatasize() 减去 paramsLength,得到的值就是调用合约方法的基础 calldata 长度,再加上 20(这里的 20 是前面 factory 的地址长度),获取到 bondingCurve 地址在整个 calldata 中的偏移量。然后调用 calldataload,将偏移量作为参数,可以获取到 bondingCurve 地址起始的 32 字节数据,其中前 20 字节是 bondingCurve 的地址,后面的 12 (0x60 / 8)字节通过 shr 操作移除,最终得到的就是 bondingCurve 的地址。

我们来看看图示,更加易懂:

其余的 factory 等获取方法同理,只是偏移量不同,最后的 add 数值不同。

看懂这块,我们同理就可以分析 ERC20 Pair 的操作,唯一的区别是在 cloneERC20Pair 方法中,最后还要在 mstore token 地址,因此 calldata 会多 20,即 81。

那么我们思考一下,为什么 Sudoswap 要采用这么复杂的操作,而不是直接将这几个字段直接存储到 Pair 中呢?最重要的就是 gas 的原因,Solidity 中处理 calldata 的操作耗费的 gas 很少,而如果将这些字段存储到 Pair 中,首先 sstore 操作就要耗费大量 gas,其次每次读取 sload 也会耗费大量 gas,与在 calldata 中操作的 gas 消耗可以说不是一个数量级的。

总结

我们看到,Sudoswap 中采取了一个几乎极致的方法来节省 gas,这其中确实是有炫技的成分,不过里面的方法与思想还是值得我们学习的。随着合约开发逐渐卷起来,我觉得这种字节码层级操作的代码以后会越来越多,这块还是值得深入研究的。

关于我

欢迎和我交流