Uniswap V4 的架构和编程技巧及现代合约编程概述

  • wongssh
  • 发布于 8小时前
  • 阅读 74

Licredity 核心开发者分享了 Uniswap V4 的架构和编程技巧,以及现代智能合约项目的目录结构和开发架构。文章还介绍了现代常见的合约特性,例如使用 soldeer 进行依赖管理,使用 Extsload 合约来访问 internal 变量,利用 BalanceDelta 机制实现更高效的清算,以及引入 unlock 机制实现终局原子性。

概述

by wong_ssh / Jeff / Licredity

Licredity 是一个基于 Uniswap V4 Hook 的项目,我们也成功拿到了 Uniswap V4 的安全审计赞助。在 Licredity 内,我编写了大量代码,包括非常大数量的内联汇编,同时 Licredity 内部所有的数学库都是由我开发和修改。但在此时分享中,我们并不会涉及数学库的实现问题。如果大家对数学库的实现感兴趣,可以直接去读 我的博客

由于 Licredity 是一个基于 Uniswap V4 的项目,所以作为核心开发者,我非常熟悉 Uniswap V4 的架构和内容,所以在此次分享内,除了 Licredity 的项目合约外,我们也会大量介绍 Uniswap V4 合约内的编程技巧。

但需要注意的是,为了保证分享的泛用性在此次分享中,我们只会聚焦于智能合约的架构和编程技巧,但不会讨论现代 DeFi 后的数学和金融原理。所以大家可能没有办法在通过此次分享具体的了解 Uniswap V4 的原理,分享的主要内容聚焦在代码细节方面。如果大家对 Uniswap V4 的总体执行逻辑感兴趣,可以去阅读 我的博客

一些本文使用的代码库的 Github 链接:

  1. Licredity core 该合约库目前正在审计,使用内部代码需要自行判断风险
  2. Licredity oracle 该合约库目前正在审计,使用内部代码需要自行判断风险
  3. Uniswap v4 core Uniswap v4 核心库,内部代码已经经过审计
  4. Uniswap V4 Periphery Uniswap V4 外围合约,内部代码已经经过审计

文件架构

在本节中,我们首先介绍一下现代智能合约项目的目录结构。我们首先可以观察一下 Uniswap V4 Core 的目录结构:

├── CONTRIBUTING.md
├── docs
├── echidna.config.yml
├── foundry.toml
├── justfile
├── lib
├── licenses
├── out
├── README.md
├── remappings.txt
├── SECURITY.md
├── snapshots
├── src
└── test

我们可以看到 lib 文件夹内保存有 Uniswap V4 的依赖。众所周知,Foundry 默认是依靠 git modules 进行依赖管理的。但是目前我们建议使用更加现代的 soldeer 进行依赖管理。 soldeer 本质上类似 npm 包管理系统,该系统会创建 soldeer.lock 文件锁定依赖以及版本。

[[dependencies]]
name = "forge-std"
version = "1.9.7"
url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_7_28-04-2025_15:55:08_forge-std-1.9.zip"
checksum = "8d9e0a885fa8ee6429a4d344aeb6799119f6a94c7c4fe6f188df79b0dce294ba"
integrity = "9e60fdba82bc374df80db7f2951faff6467b9091873004a3d314cf0c084b3c7d"

[[dependencies]]
name = "uniswap-v4-core"
version = "4"
url = "https://soldeer-revisions.s3.amazonaws.com/uniswap-v4-core/4_28-02-2025_23:27:23_uniswap-v4-core-4.zip"
checksum = "cd91c1b34ad73028bddaeb9fd676a957962f7e4ab671998f4439aa1f474010c8"
integrity = "859f1409f40b591e2cf337d02591d4ec53013631d2e958a9f03b84af88410abb"

这样我们可以在 dependencies 内储存具体的代码。相比于 git 模块管理,soldeer 管理更加高效。目前,soldeer 所有的命令都可以直接在 foundry 工具下调用。如果读者希望获得更多关于 soldeer 的信息,可以阅读 Soldeer as a Package Manager

对于 remappings.txt,目前并没有统一共识。在构建 Licredity 项目时,我们选择不使用 remappings.txt 而是只使用 foundry.toml 配置文件内的 remappings 配置。

remappings = [\
    "@forge-std/=dependencies/forge-std-1.9.7/src",\
    "@uniswap-v4-core/=dependencies/uniswap-v4-core-4/src"\
]

关于 src 目录,我们会在后文介绍现代合约架构时进行分析。此处,我们可以简单看一下其他的目录,比如 snapshots 目录。该目录在 Licredity 内并不存在,该目录用于存储测试中的 gas 消耗,主要用于判断某些代码修改是否优化了 gas。我们需要在测试内使用 vm.startSnapshotGas / vm.stopSnapshotGas / vm.snapshotGasLastCall 手动记录 gas 消耗。在 v4-core 内存在以下代码:

function testTick_tickSpacingToMaxLiquidityPerTick_gasCostMinTickSpacing() public {
    vm.startSnapshotGas("tickSpacingToMaxLiquidityPerTick_gasCostMinTickSpacing");
    tickSpacingToMaxLiquidityPerTick(TickMath.MIN_TICK_SPACING);
    vm.stopSnapshotGas();
}

function testMint() public {
    token.mint(address(0xBEEF), 1337, 100);
    vm.snapshotGasLastCall("ERC6909Claims mint");

    assertEq(token.balanceOf(address(0xBEEF), 1337), 100);
}

vm.startSnapshotGasvm.stopSnapshotGas(); 用于记录代码之间的 gas 消耗,而 vm.snapshotGasLastCall 用于记录上一次调用的 gas 消耗。由于 Licredity 并没有严格的 gas 方面的统计需求,所以我们并没有使用 snapshots 进行记录。

有同学可能好奇 echidna.config.yml 的作用, echidna 是一个高级的不变量测试工具。事实上,Uniswap V4 并没有大量使用该工具。假如读者对不变量测试感兴趣,可以关注一下 Recon。该服务商维护了一套不变量测试的通用框架 create-chimera-app。当然,我也有一篇部分完成的 基于 Recon 与 Medusa 构建不变量测试 的文章,也可以作为不变量测试的入门文章。

最后,我们可以关注一下 test 文件夹,该文件夹内架构基本与 src 一致,每一个目录下都是 src 内合约对应的单元测试和 fuzz 测试。

.
├── bin
├── CurrencyReserves.t.sol
├── CustomAccounting.t.sol
├── DynamicFees.t.sol
├── DynamicReturnFees.t.sol
├── ERC6909Claims.t.sol
├── Extsload.t.sol
├── js-scripts
├── libraries
├── ModifyLiquidity.t.sol
├── NoDelegateCall.t.sol
├── PoolManager.clear.t.sol
├── PoolManager.gas.spec.ts
├── PoolManager.swap.t.sol
├── PoolManager.t.sol
├── PoolManagerInitialize.t.sol
├── ProtocolFeesImplementation.t.sol
├── SkipCallsTestHook.t.sol
├── Sync.t.sol
├── Tick.t.sol
├── types
└── utils

比较值得关注的是 bin 文件夹和 js-script 文件夹。其中 bin 文件夹内包含合约的二进制字节码,我们可以看到 test/bin/v3Factory.bytecode 文件。该文件内其实是 Uniswap V3 Factory 合约的字节码。为什么 Uniswap V4 内包含 v3 的代码? 这是因为 v4 内的核心的流动性和兑换逻辑是与 Uniswap V3 一致的。在 test/PoolManager.swap.t.sol 文件内,Uniswap V4 与 Uniswap V3 进行了等效性测试。

那么我们为什么不能直接导入V3的代码?这是因为 v4 在合约内锁定了 pragma solidity 0.8.26;。而 v3-core 则锁定了 pragma solidity =0.7.6;。如果直接导入 v3 的代码会因为版本冲突而无法编译。在 Licredity 内,我们的核心合约锁定了 pragma solidity =0.8.30; 版本,这导致我们无法通过导入 v4 代码部署 uniswap v4 合约,所以我们使用类似方案,将 v4 编译成字节码后部署。关于如何获取编译后的字节码,在此处,我们提供一个简单的命令:

forge inspect src/PoolManager.sol bytecode | sed 's/0x//' | xxd -r -p > contract.bytecode

我们如何部署这些二进制版本的合约呢?在 licredity-v1-periphery 合约内,我们进行了很多二进制合约的部署,代码如下:

function deployUniswapV4Core(address initialOwner, bytes32 salt) public returns (IPoolManager poolManager) {
    bytes memory args = abi.encode(initialOwner);
    bytes memory bytecode = vm.readFileBinary("test/bin/v4PoolManager.bytecode");
    bytes memory initcode = abi.encodePacked(bytecode, args);

    assembly {
        poolManager := create2(0, add(initcode, 0x20), mload(initcode), salt)
    }

    vm.label(address(poolManager), "UniswapV4PoolManager");
}

此处使用 vm.readFileBinary 读取二进制文件,然后直接使用 create2 指令部署。而最后的 vm.label 是为了方便在打印 trace 时观察合约。

警告, licredity-v1-periphery 合约目前还处于积极开发过程中,目前尚未审计

当然,这里需要特别注意,Foundry 本身默认 readFileBinary 等文件读取函数必须要按照权限执行,即文件读取函数无法读取权限目录外的文件,该目录权限需要在 foundry.toml 内指定。比如我们在 licredity-v1-periphery 内的 foundry.toml 内包含以下配置:

fs_permissions = [\
    { access = "read", path = "./test/bin" },\
    { access = "read", path = "./out" },\
    { access = "read", path = "./test/test_data" },\
]

回到 Uniswap v4 core 的 test 目录,该目录内包含 test/js-scripts 文件夹,该文件夹内一般存储数学计算。比如 test/js-scripts/src/getSqrtPriceAtTick.ts 内包含 ts 实现的 tick 到 price 的转化,tick 到 price 实际上是一个指数计算。该计算对应的 typescript 代码如下:

import Decimal from "decimal.js";
import { ethers } from "ethers";

const tickArray = process.argv[2].split(",");
const resultsArray = [];
for (let tick of tickArray) {
  const jsResult = new Decimal(1.0001).pow(tick).sqrt().mul(new Decimal(2).pow(96)).toFixed(0);
  resultsArray.push(jsResult);
}
process.stdout.write(ethers.utils.defaultAbiCoder.encode(["uint160[]"], [resultsArray]));

Unsiwap V4 使用 solidity 也实现了 getSqrtPriceAtTick 的实现,但我们并不确定该实现的正确性,所以在这种情况下,我们一般引入使用 typescript 或 python 内数学库构建的代码,对这套代码的输出与 solidity 输出进行比较。在 licredity-v1-oracle 内部,我们使用了 EMA 算法计算报价平均值,此处我们使用了 Python 构建的代码与 solidity 的输出进行了比较:

import mpmath as mp
import argparse

mp.dps = 100

def ema_price(last_price, now_sqrt_price, time_diff):
    alpha = mp.exp(-time_diff / mp.mpf(600))

    now_price = mp.power(now_sqrt_price, 2) / mp.power(2, 96)
    if (now_price > last_price * (1 + 0.015625)):
        now_price = last_price * (1 + 0.015625)
    elif (now_price < last_price * (1 - 0.015625)):
        now_price = last_price * (1 - 0.015625)

    new_price = alpha * now_price + (1 - alpha) * last_price
    return new_price / mp.power(2, 96) * mp.mpf(1e18)

def get_oralce_debt(ema_price, amount):
    return mp.mpf(amount) / mp.mpf(ema_price)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('last_price', type=int)
    parser.add_argument('now_sqrt_price', type=int)
    parser.add_argument('time_diff', type=int)
    # parser.add_argument('amount', type=int)
    args = parser.parse_args()

    last_price = mp.mpf(args.last_price)
    now_sqrt_price = mp.mpf(args.now_sqrt_price)

    ema_price = ema_price(last_price, now_sqrt_price, args.time_diff)
    # token_output = get_oralce_debt(ema_price, args.amount)
    print(int(ema_price), end="")

我们使用了 mpmath 进行高精度计算。这里可能需要补充一下,标准的 Python 自带的计算标准库实际上精度是不足的,在进行大数计算时会出现精度丢失的问题。

为了实现在 foundry 测试过程中调用外部代码,我们会使用 foundry 的 ffi 特性。该特性允许 foundry 在测试过程中调用命令行工具并将返回值输入到测试中,我们可以看到在 Uniswap V4 Core 中位于 test/utils/JavascriptFfi.sol 文件内的 runScript 函数,该函数接受输入后拼接触发 js 文件计算数据。在 _modifyLiquidityJS 内,我们可以看到对返回值的处理, ffi 函数默认返回 bytes 类型,需要我们手动解码:

string memory scriptName = "forge-test-getModifyLiquidityResult";
bytes memory jsResult = runScript(scriptName, jsParameters);

int128[] memory result = abi.decode(jsResult, (int128[]));
int128 jsDelta0 = result[0];
int128 jsDelta1 = result[1];
return (jsDelta0, jsDelta1);

值得注意的, ffi 会大幅度拖慢测试的速度,所以所有使用 ffi 的模糊测试,Uniswap 都使用行内配置限制了 fuzz 的次数:

/// forge-config: default.fuzz.runs = 10
/// forge-config: pr.fuzz.runs = 10
/// forge-config: ci.fuzz.runs = 500
/// forge-config: debug.fuzz.runs = 10

开发架构

在上文内,我们讨论了项目的文件架构,主要介绍了以下内容:

  1. 依赖管理
  2. 版本不兼容合约的测试环境部署
  3. ffi 引入其他编程语言编写的代码进行等效性测试

在本节中,我们主要讨论上文中没有讨论的 src 文件夹内的内容。换言之,本节主要专注于合约的现代架构问题。同学们可能在 solidity 合约编程学习过程中学习了相当多与继承有关的内容,比如 C3 Linearization

继承是复杂的,所以在现代编程语言中,以 Rust 为代表的编程语言提出了“组合大于继承”的原则。在现代 solidity 编程中,我们往往倾向于较少使用继承而较多使用组合。可能有同学好奇如何在 solidity 编程内实现组合的语义,此时我们就会使用到 library 以及自定义类型等语法结构。

v4-coresrc 文件夹内,我们可以看到 librariestypes 两种类型。其中 types 文件夹内定义了很多自定义类型。自定义类型是 solidity 较新的特性,如果大家阅读一些较早的 solidity 教程应该无法看到此特性的介绍。我们可以简单阅读 Currency 类型。我们会使用如下方法创造自定义类型:

type Currency is address;

此处我们定义了 Currency 类型,该类型的底层其实就是地址类型,但是该类型提供了比地址更加丰富的功能。我们可以使用 Currency.wrapaddress 转化为 Currency 类型,使用 Currency.unwrap 进行 Currencyaddress 的转化。

当然,其实 solidity 内的结构体也是一类自定义类型,比如 PoolKey 的定义如下:

/// @notice Returns the key for identifying a pool
struct PoolKey {
    /// @notice The lower currency of the pool, sorted numerically
    Currency currency0;
    /// @notice The higher currency of the pool, sorted numerically
    Currency currency1;
    /// @notice The pool LP fee, capped at 1_000_000. If the highest bit is 1, the pool has a dynamic fee and must be exactly equal to 0x800000
    uint24 fee;
    /// @notice Ticks that involve positions must be a multiple of tick spacing
    int24 tickSpacing;
    /// @notice The hooks of the pool
    IHooks hooks;
}

我们继续阅读 Currency 的合约,该合约接下来为 Currency 附加了运算符重载的功能:

using {greaterThan as >, lessThan as <, greaterThanOrEqualTo as >=, equals as ==} for Currency global;

function equals(Currency currency, Currency other) pure returns (bool) {
    return Currency.unwrap(currency) == Currency.unwrap(other);
}

// ...

此处我们看到了有趣的关键词 globalglobal 的含义是当开发者导入 Currency 类型后,隐式导入标记有 globallibrary

开发者自定义类型极大的提高了以太坊的开发体验,比如我们可以写出以下代码:

/// @inheritdoc IPoolManager
function sync(Currency currency) external {
    // address(0) is used for the native currency
    if (currency.isAddressZero()) {
        // The reserves balance is not used for native settling, so we only need to reset the currency.
        CurrencyReserves.resetCurrency();
    } else {
        uint256 balance = currency.balanceOfSelf();
        CurrencyReserves.syncCurrencyAndReserves(currency, balance);
    }
}

开发者自定义类型的另一个好处是避免某些带有特定精度的类型与通用类型在无意中进行误操作计算。在 licredity 的 core 合约内,我们存在一个 InterestRate 类型,该类型代表利率,且精度为 1e27。我们定义了该类型,并为其编写了 calculateInterest 函数计算复利。但我们没有该类型提供任何运算符重载,如此我们就可以避免不小心编写了如下代码:

InterestRate interestRate = _priceToInterestRate(oracle.quotePrice()) * 2;

当存在上述代码时,执行 forge build 会产生如下报错:

Error: Compiler run failed:
Error (2271): Built-in binary operator * cannot be applied to types InterestRate and int_const 2. No matching user-defined operator found.
   --> src/Licredity.sol:773:41:
    |
773 |             InterestRate interestRate = _priceToInterestRate(oracle.quotePrice()) * 2;

肯定有同学好奇开发者自定义类型的本质是什么? 实际上开发者自定义类型一个在编译器进行语法检查时发挥作用的语法。在完成语法检查后,编译器会将开发者自定义类型转化为对应的底层类型,比如上文内出现的 Currency 会被还原为 address 类型。我们可以在 fuzz 测试时,直接使用自定义类型作为输入:

function test_fuzz_fromId_toId_opposites(Currency currency) public view {
    assertEq(Currency.unwrap(currency), Currency.unwrap(currencyTest.fromId(currencyTest.toId(currency))));
}

这是因为编译后, Currency 会被直接转化为 address 类型,所以 Foundry 的 fuzz 数据生成工具可以生成该数据。除了上述的 wrap 或者 unwrap 方法处理自定义类型,还存在一种在开发中常用的直接使用内联汇编生成自定义类型的方法:

/// @notice Returns value equal to keccak256(abi.encode(poolKey))
function toId(PoolKey memory poolKey) internal pure returns (PoolId poolId) {
    assembly ("memory-safe") {
        // 0xa0 represents the total size of the poolKey struct (5 slots of 32 bytes)
        poolId := keccak256(poolKey, 0xa0)
    }
}

上述代码内 keccak256 的返回值实际上是 bytes32,但是此处在函数签名内部将其指定为 PoolId。solidity 编译器在处理自定义类型时不会校验 yul 会变返回的数值与自定义类型的底层是否一致,这种一致性由内联汇编开发者自行保证。最后,我们需要知道自定义类型在 ABI 中也会被转化为底层类型。在 Licredity 内的 core 合约内,我们存在如下函数:

function stageNonFungible(NonFungible nonFungible) external {

该函数使用了 type NonFungible is bytes32; 类型。在 solidity 编译器的输出中,我们可以看到如下输出:

"stageNonFungible(bytes32)": "bae34ca4",

在计算 ABI 的函数选择器时,编译器自动进行了类型转化。

对于 library 而言,大家可能都比较熟悉,因为 library 是一个较早的语法特性,大部份 solidity 教程都会提及。首先,我们需要知道 library 的基础原理, library 的调用存在两种方法:

  1. JUMP 跳转,所有使用 internal 的函数都会使用该方法被调用。具体原理是在执行到指定的在 library 内的函数时,EVM 的程序计数器会在 JUMP 指令执行后跳转到 library 的代码处
  2. DelegateCall 跳转,使用 public 或者 view 修饰符的函数。如果 library 内包含这些函数,那么 library 就会被额外部署,代码使用 delegate call 或者 static call 方法调用。

AAVE v3 的代码内大部份的 logic 内的核心 library 内的函数都使用了 public 修饰符,这是因为 AAVE 的 logic 内的逻辑代码非常复杂,如果使用 internal 的修饰符会导致合约体积爆炸

可能在很久之前,一些教程会提到 library 内不能包含合约状态的读写,这其实是错误的。正如上文所述,使用 jumpdelegate call 方法,其实都可以访问到合约存储。此处我们可以 Licredity core 合约内的 src/types/Position.sol。你可以看到以下代码:

/// @notice Sets the owner of a position
/// @param self The position to set owner for
/// @param owner The new owner of the position
function setOwner(Position storage self, address owner) internal {
    assembly ("memory-safe") {
        // self.owner = owner;
        sstore(add(self.slot, OWNER_OFFSET), and(owner, 0xffffffffffffffffffffffffffffffffffffffff))
    }
}

此处我们要求调用该 library 的合约给出 Position storage self 类型参数,这个参数本质上是存储指针,指向了 Position 所在的存储空间的起点。众所周知,结构体在存储内是线性分布的,所以此处我们可以通过 add(self.slot, OWNER_OFFSET) 计算出 self.owner 位于的存储位置,并使用 sstore 将其写入。上述代码内的 and(owner, 0xffffffffffffffffffffffffffffffffffffffff) 是使用 and 方法清空 owner 可能存在的高位垃圾值。

所有在 library 内的存储访问本质上都是需要传入存储指针,然后进行写入。在 Uniswap V4 Core 内部,最核心的 library 是 Pool.sol,该文件内包含以下复杂的结构体:

struct State {
    Slot0 slot0;
    uint256 feeGrowthGlobal0X128;
    uint256 feeGrowthGlobal1X128;
    uint128 liquidity;
    mapping(int24 tick => TickInfo) ticks;
    mapping(int16 wordPos => uint256) tickBitmap;
    mapping(bytes32 positionKey => Position.State) positions;
}

我们实际上也可以通过 State storage self 在 library 内访问到该结构体内的任何数据。在 Licredity core 内,我们在 src/BaseERC20.sol 内的 transferFrom 使用内联汇编表演过如何访问并写入 mapping 类型。 部分代码如下:

mstore(0x00, caller())
mstore(0x20, add(ownerDataSlot, ALLOWANCES_OFFSET))
let allowanceSlot := keccak256(0x00, 0x40)
let _allowance := sload(allowanceSlot)

总而言之,在现代 DeFi 框架下,大部份功能都应该优先考虑在 library 内实现,而且开发者应该在开发前考虑哪些 library 内的逻辑需要和数据类型绑定,如果存在这种逻辑与数据类型绑定的情况,请优先考虑使用开发者自定义类型。

但是上述说法绝对不是否认继承的功能。继承目前仍被使用,但继承的使用原则是:

  1. 尽可能不要使用 override 等方法重载父合约的函数,override 的重载函数只应该服务于父合约需要获取子合约数据的情况
  2. 不要进行多层级的继承

关于原则 1,我们可以简单讨论一下,在 Uniswap v4 core 合约内,存在以下函数:

/// @notice Implementation of the _getPool function defined in ProtocolFees
function _getPool(PoolId id) internal view override returns (Pool.State storage) {
    return _pools[id];
}

该函数的实际目的是方便 ProtocolFees 合约修改 pool 的协议手续费。

当然,继承作为一种历史悠久的开发理念,在很多情况下都有其用武之地,但是开发者不应该优先考虑使用继承,而是先考虑是否可以利用 library 的模式实现。在 Uniswap v4 periphery 合约内,uniswap 开发者就违反了上述规则,构建了继承关系稍微复杂的合约。

现代特性

本节主要讨论现代常见的一些合约特性。这些约定俗成的特性实现可以优化合约效率或者提高用户体验。限于笔者水平,本节只会抛砖引玉的列出一些常见的现代合约特性。

首先需要被介绍的就是不使用 view 函数而是使用 Extsload 合约。在 Licredity core 合约内,几乎所有的状态变量都是 internal 的,使用 internal 的好处是避免 solidity 编译器增加 view 方法,大量的 view 方法增加会大幅度提高合约体积。

PoolKey internal poolKey;
uint256 internal totalDebtShare = 1e6; // can never be redeemed, prevents inflation attack and behaves like bad debt
uint256 internal totalDebtBalance = 1; // establishes the initial conversion rate and inflation attack difficulty
uint256 internal accruedDonation;
uint256 internal accruedProtocolFee;
uint256 internal lastInterestCollectionTimestamp;
uint256 internal baseAmountAvailable;
uint256 internal debtAmountOutstanding;
uint256 internal positionCount;
mapping(bytes32 => uint256) internal liquidityOnsets; // maps liquidity key to its onset timestamp
mapping(uint256 => Position) internal positions;

但我们该如何访问这些 internal 的变量? 一个现代的方案是使用 Extsload 合约,该合约内容很短,此处直接全部显示一下:

abstract contract Extsload is IExtsload {
    /// @inheritdoc IExtsload
    function extsload(bytes32 slot) external view returns (bytes32 value) {
        assembly ("memory-safe") {
            value := sload(slot)
        }
    }

    /// @inheritdoc IExtsload
    function extsload(bytes32 startSlot, uint256 nSlots) external view returns (bytes32[] memory) {
        assembly ("memory-safe") {
            let memptr := mload(0x40)
            let start := memptr
            // A left bit-shift of 5 is equivalent to multiplying by 32 but costs less gas.
            let length := shl(5, nSlots)
            // The abi offset of dynamic array in the returndata is 32.
            mstore(memptr, 0x20)
            // Store the length of the array returned
            mstore(add(memptr, 0x20), nSlots)
            // update memptr to the first location to hold a result
            memptr := add(memptr, 0x40)
            let end := add(memptr, length)
            for {} 1 {} {
                mstore(memptr, sload(startSlot))
                memptr := add(memptr, 0x20)
                startSlot := add(startSlot, 1)
                if iszero(lt(memptr, end)) { break }
            }
            return(start, sub(end, start))
        }
    }

    /// @inheritdoc IExtsload
    function extsload(bytes32[] calldata slots) external view returns (bytes32[] memory) {
        assembly ("memory-safe") {
            let memptr := mload(0x40)
            let start := memptr
            // for abi encoding the response - the array will be found at 0x20
            mstore(memptr, 0x20)
            // next we store the length of the return array
            mstore(add(memptr, 0x20), slots.length)
            // update memptr to the first location to hold an array entry
            memptr := add(memptr, 0x40)
            // A left bit-shift of 5 is equivalent to multiplying by 32 but costs less gas.
            let end := add(memptr, shl(5, slots.length))
            let calldataptr := slots.offset
            for {} 1 {} {
                mstore(memptr, sload(calldataload(calldataptr)))
                memptr := add(memptr, 0x20)
                calldataptr := add(calldataptr, 0x20)
                if iszero(lt(memptr, end)) { break }
            }
            return(start, sub(end, start))
        }
    }
}

简单来说,该合约对外提供了几个函数用于直接访问当前合约的存储槽。其中 extsload(bytes32 slot) 函数一般用于读取单个状态变量,比如以下代码:

function getTotalDebt(ILicredity manager) internal view returns (uint256 totalShares, uint256 totalAssets) {
    totalShares = uint256(manager.extsload(bytes32(TOTAL_DEBT_SHARE_OFFSET)));
    totalAssets = uint256(manager.extsload(bytes32(TOTAL_DEBT_BALANCE_OFFSET)));
}

extsload(bytes32 startSlot, uint256 nSlots) 主要用于读取结构体变量,比如 Uniswap V4 的 StateLibrary 存在以下代码:

/**
    * @notice Retrieves the position information of a pool at a specific position ID.
    * @dev Corresponds to pools[poolId].positions[positionId]
    * @param manager The pool manager contract.
    * @param poolId The ID of the pool.
    * @param positionId The ID of the position.
    * @return liquidity The liquidity of the position.
    * @return feeGrowthInside0LastX128 The fee growth inside the position for token0.
    * @return feeGrowthInside1LastX128 The fee growth inside the position for token1.
    */
function getPositionInfo(IPoolManager manager, PoolId poolId, bytes32 positionId)
    internal
    view
    returns (uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128)
{
    bytes32 slot = _getPositionInfoSlot(poolId, positionId);

    // read all 3 words of the Position.State struct
    bytes32[] memory data = manager.extsload(slot, 3);

    assembly ("memory-safe") {
        liquidity := mload(add(data, 32))
        feeGrowthInside0LastX128 := mload(add(data, 64))
        feeGrowthInside1LastX128 := mload(add(data, 96))
    }
}

最后的 extsload(bytes32[] calldata slots) 使用较少。

二是单体架构。当然,是否选择单体架构需要视项目情况确定,比如 Licredity 虽然可以使用单体架构,但是最初为了简化合约构建以及考虑到项目实际需求,我们就选择了非单体架构的情况。Uniswap V4 选择了单体架构,单体架构主要为 Uniswap v4 带来以下几个好处:

  1. 优化复杂的链式兑换,在 Uniswap 内,我们经常执行形如 A -> B -> C 的链式兑换,在 v3 和 v2 内,我们在进行 swap 时需要先计算 Pool A / B 的地址和 Pool B / C 的地址,然后请求两个地址。在 EVM 内,对于 call 调用的定价是带有两个维度的,分别是调用目标地址是否是 warm 的以及调用的 calldata 长度。当我们使用单体合约后,进行 swap 只需要调用 v4 核心合约,这使得调用过程中除了第一次调用,v4 的核心合约始终是 warm 的,降低了调用 cold 合约的 gas 消耗
  2. 与 Flash Account 机制配合进一步降低 gas 消耗

为什么 Licredity 不使用单体架构? 这是因为 Licredity 较少存在跨池的操作,我们评估此时使用单体架构将增加开发成本但并不会显著提高用户体验。

三是 BalanceDelta 机制。 BalanceDelta 机制与传统金融机构清算时的轧差机制是一致的,我们会在 transit storage 内记录用户与协议之间的资产与负债情况。比如用户使用 take 函数在协议内提取资产时,我们就会在 delta 内以负数的形式记录用户存在对协议的负债:

/// @inheritdoc IPoolManager
function take(Currency currency, address to, uint256 amount) external onlyWhenUnlocked {
    unchecked {
        // negation must be safe as amount is not negative
        _accountDelta(currency, -(amount.toInt128()), msg.sender);
        currency.transfer(to, amount);
    }
}

Uniswap V4 core 定义了几个函数

在 delta 机制下,用户可以一次性进行多笔交互并在最后统一清算,避免了过去每笔交易都都需要额外进行清算的 gas 消耗。另外,delta 机制避免了 callback 地狱的出现,此处我们可以观察没有存在 delta 机制的 uniswap v3 内用于清算的代码:

if (zeroForOne) {
    if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));

    uint256 balance0Before = balance0();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
    if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));

    uint256 balance1Before = balance1();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
}

可以看到每次清算都会触发一次对 msg.sender 的回调。一些基于 Uniswap v3 的路由合约会在此处 callback 回调进行类似递归展开的逻辑,比如在 Lotus Router 内部,使用以下方法进行链式兑换:

MarketABMarketBCLotusMarketABMarketBCLotusswap(B, C)transfer CuniswapV3SwapCallbackswap(A, B)transfer BuniswapV3SwapCallbacktransfer Atransfer Breturnreturnreturnreturn

上述递归流程是对大部份开发者不友好的,引入 delta 逻辑后,这些复杂的基于 callback 逻辑的清算都可以被转化为直观的递推逻辑。在 Licredity 内部,我们引入类似 balance delta 机制的原因就是如此。

读者也许好奇为什么 v3 使用了 callback 机制进行清算,这是因为存在奇怪的代币带有 fee on trasnfer 机制,即这些代币在进行 transfer 时,transfer 代币接收者获得的代币数量与发送者给出的代币数量并不一致,差额是代币发行方拿走的费用。该机制较为少见,但是审计公司在审计时一般会检查协议是否可以与 fee on transfer 代币兼容。

Licredity 使用了 balance delta 进行了这部分清算,该代码如下:

/// @inheritdoc ILicredity
function stageFungible(Fungible fungible) external {
    assembly ("memory-safe") {
        // stagedFungible = fungible;
        tstore(stagedFungible.slot, and(fungible, 0xffffffffffffffffffffffffffffffffffffffff))
    }

    if (!fungible.isNative()) {
        stagedFungibleBalance = fungible.balanceOf(address(this));
    }
}

function _getStagedFungibleAndAmount() internal view returns (Fungible fungible, uint256 amount) {
    fungible = stagedFungible; // no dirty bits

    if (fungible.isNative()) {
        amount = msg.value;
    } else {
        assembly ("memory-safe") {
            // require(msg.value == 0, NonZeroNativeValue());
            if iszero(iszero(callvalue())) {
                mstore(0x00, 0x19d245cf) // 'NonZeroNativeValue()'
                revert(0x1c, 0x04)
            }
        }

        amount = fungible.balanceOf(address(this)) - stagedFungibleBalance;
    }
}

当用户协议向 Licredity 的核心合约存入资产时,我们要求用户首先调用 stageFungible 函数在 transit storage 内缓存需要存入的代币以及目前 Licredity 核心合约内这种代币已有的数量。然后用户可以向 Licredity 合约内转入代币,之后调用 depsoitFungible 函数。其中 depositFungible 会在内部使用 _getStagedFungibleAndAmount 获取在 stageFungible 后中用户存入的代币数量。

四是 unlcok 与终局原子性。可能很多同学都听说过 Flash loan 系统,该系统利用交易的原子性允许用户在单笔交易内使用近乎无限的资产。在这里做一个小提示,目前 Morpho 和 Uniswap v4 的 falsh loan 无需支付手续费,建议开发者优先考虑使用这两个协议。Flash loan 在最新的 Uniswap v4 以及我们的 Licredity 协议中都被扩展为了“终局原子性”。

终局原子性是我在编写此文时想到的术语,该术语描述当其他合约与我们的智能合约交互时,大部份交互应该首先调用 unlock 函数,然后进行自己的交互,在最终退出交互时,我们的协议合约会检查用户是否满足部分要求。比如 Licredity 在 unlock 最后会检查用户的头寸是否健康:

for (uint256 i = 0; i < items.length; ++i) {
    (,,, bool isHealthy) = _appraisePosition(positions[uint256(items[i])]);

    // require(isHealthy, PositionIsUnhealthy());
    assembly ("memory-safe") {
        if iszero(isHealthy) {
            mstore(0x00, 0x5fba8098) // 'PositionIsUnhealthy()'
            revert(0x1c, 0x04)
        }
    }
}

Locker.lock();

上述表述意味着我们并不关心用户在交互过程中的操作,比如用户可以首先进行借款然后再填充保证金,或者用户可以先填充保证金再进行借款。由于存在 unlock 内的终局确定,所以用户拥有了更高的灵活性。在 Uniswap v4 内,Uniswap v4 在 unlock 最后检查了用户是否清算了所有资产:

/// @inheritdoc IPoolManager
function unlock(bytes calldata data) external override returns (bytes memory result) {
    if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();

    Lock.unlock();

    // the caller does everything in this callback, including paying what they owe via calls to settle
    result = IUnlockCallback(msg.sender).unlockCallback(data);

    if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
    Lock.lock();
}

此处的 NonzeroDeltaCount 是一个记录用户未清算资产数量的 transit storage 变量。

引入 unlock 的坏处是 unlock 限制了 EOA 与合约的直接交互,用户与合约的交互必须通过外围合约进行。在 Licredity Periphery 的 src/LicredityAccount.sol 合约内存在以下代码:

function execute(ILicredity licredity, bytes calldata inputs, uint256 deadline)
    external
    payable
    isNotLocked
    checkDeadline(deadline)
{
    usingLicredity = licredity;

    usingLicredity.unlock(inputs);

    usingLicredity = ILicredity(address(0));
    usingLicredityPositionId = 0;
}

function unlockCallback(bytes calldata data) external returns (bytes memory) {
    if (msg.sender == address(poolManager)) {
        (bytes calldata actions, bytes[] calldata params) = data.decodeActionsRouterParams();
        uint256 numActions = actions.length;
        require(numActions == params.length, InputLengthMismatch());

        for (uint256 actionIndex = 0; actionIndex < numActions; actionIndex++) {
            uint256 action = uint8(actions[actionIndex]);

            _handleUniswapV4Action(action, params[actionIndex]);
        }
    } else if (msg.sender == address(usingLicredity)) {
        (bytes calldata actions, bytes[] calldata params) = data.decodeActionsRouterParams();
        uint256 numActions = actions.length;
        require(numActions == params.length, InputLengthMismatch());

        for (uint256 actionIndex = 0; actionIndex < numActions; actionIndex++) {
            uint256 action = uint8(actions[actionIndex]);

            _handleLicredityAction(action, params[actionIndex]);
        }
    } else {
        revert NotSafeCallback();
    }

    return "";
}

上述代码的目的是将用户执行的 calldata 转发给 Licredity 核心合约的 unlock 函数,核心合约的 unlock 函数会对调用者执行 unlockCallback 函数,之后我们在 unlockCallback 内解析 calldata 并执行操作。假如读者对 gas 优化感兴趣,非常建议读者阅读 v4-periphery 内部的 src/libraries/CalldataDecoder.sol 合约,该合约演示了如何高效的在不使用内存的情况下解析 calldata 内的数据。部分代码如下:

/// @dev equivalent to: abi.decode(params, (Currency, Currency, address)) in calldata
function decodeCurrencyPairAndAddress(bytes calldata params)
    internal
    pure
    returns (Currency currency0, Currency currency1, address _address)
{
    assembly ("memory-safe") {
        if lt(params.length, 0x60) {
            mstore(0, SLICE_ERROR_SELECTOR)
            revert(0x1c, 4)
        }
        currency0 := calldataload(params.offset)
        currency1 := calldataload(add(params.offset, 0x20))
        _address := calldataload(add(params.offset, 0x40))
    }
}

使用内联汇编的原因是 abi.decode(params, (Currency, Currency, address)) 并没有被高效优化

上述大部份较新的框架其实都依赖 transit storage 的特性。transit storage 提供了交易过程中中始终可以被访问的原语,这使得部分过去依赖复杂递归方法的合约编写可以简化。

总结

本文主要介绍 DeFi 项目应该存在的项目文件架构,常见的文件夹有:

  1. dependencieslib 文件夹用于存储项目依赖,目前建议使用 forge soldeer 作为依赖管理工具
  2. snapshots 存储项目测试工程中的 gas 消耗,主要用于在 gas 优化时提供数据支持
  3. test 文件夹内除了项目的测试文件外,往往还存在 bin 文件夹存储与当前合约版本不兼容的其他合约二进制字节码; {other}-script 内存储其他语言编写的脚本,用于 ffi 调用

现代合约项目往往也存在一些常见的架构,本文主要介绍了:

  1. extsload 合约提供了 no view 的编程方法,可以不再使用 view 函数暴露合约内的状态变量
  2. 单体架构。对于需要协议内跨池交互频繁的项目适用,假如不存在大量跨池交互,可以考虑不使用单体架构
  3. Balance Delta 机制。通过引入轧差实现更加高效的清算,用户可以在一笔交易内进行多次交互,然后进行统一清算
  4. unlock 与终局原子性。引入 unlock 机制,并在 unlock 的最后进行核心属性的检查,可以使得用户以任意顺序与协议交互,只需要保障最终的属性检查通过即可
点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
wongssh
wongssh
江湖只有他的大名,没有他的介绍。