从模糊测试Centrifuge Protocol 中学到的教训 第1部分

  • Recon
  • 发布于 2024-05-04 18:58
  • 阅读 31

本文介绍了Centrifuge协议在实施模糊测试套件过程中的经验,讨论了在高覆盖率测试中的策略及方法,包括使用随机化机制来扩展合约的测试配置。文章分为多个部分,包括策略概述、Centrifuge的介绍、关键经验总结与结论,结构清晰且逻辑性强,适合对区块链测试技术感兴趣的读者。

概述

最近,Centrifuge Protocol 团队邀请了 Recon 团队在他们的系统中实施模糊测试套件。此次合作持续了三周,并分为两个阶段。

我们首先通过我们的不变量编写研讨会来引导 Centrifuge 团队,在过程中帮助他们利用对系统的知识来指定其不变量。

由于这个阶段的主要目标是实现目标合约的高行覆盖率,更快的(实验性)Medusa 模糊测试器帮助我们在研讨会中实现对定义属性的覆盖扩展的短迭代周期。

然后,我们转向更准确的长时间运行,使用 Echidna 在合作的第二阶段进行。这些较长的运行是通过 Recon Pro 作业功能执行的,该功能允许团队在云服务上并行运行多个作业,而不必在个人计算机上分配计算资源。

在本文中,我们将关注这次合作的第一阶段,以获取有关如何实现高覆盖率的测试方法的一些有用见解,以及通过模糊测试引入额外随机性到系统中的创新方法,从而测试更多可能的系统状态。

Centrifuge简介

Centrifuge 是一个现实世界资产(RWA)借贷协议。Centrifuge 的流动性池允许在任何与 EVM 兼容的区块链上部署 Centrifuge RWA 池。RWA 池允许投资者根据他们的风险偏好通过不同的分级提供流动性,每个分级由单独的 LiquidityPoolTrancheToken 定义。

每个 TrancheToken 可以有多个基础资产支持它,因此基础资产与 RWA 池之间的关系是多对多的,如下图所示。

多个基础资产可以用于支持每个分级代币,作为流动性池的股份。每个流动性池则作为 RWA 池的流动性来源,从而在基础资产和 RWA 池之间形成多对多的关系。

Centrifuge 中的 LiquidityPool 实现了 ERC-7540(ERC-4626 金库的扩展)标准,允许投资者将稳定币作为基础资产进行存入,并以 TrancheToken 的形式获得池的股份。

Centrifuge 链是一个单独的可信链,它为以太坊上的 LiquidityPool 创建了一个通道,处理投资者在以太坊侧进行的操作(存款、取款等)。

Centrifuge 架构的高级概述

在本次评审中,只有上图中的管理器和流动性池 合约是我们关注的,因为网关 + 路由器被确定更像是系统其他部分消息的处理器/过滤器,因此对模糊测试没有提供有趣的结果(见要点 2)。

扩展覆盖的新方法

实现高覆盖率是任何模糊测试活动的最终目标,但实现它的机制决定了系统中是否探索了所有可能的代码路径,或者是否只探索了这些路径中的一小部分。

正如我们下面将看到的,具有不同配置的多个合约实现的能力引入了新的可能路径,这迫使我们扩展覆盖的定义,以包括不仅是行覆盖率,还有可能的合约实施组合。

现在我们将看一下此次合作的三个主要要点,以及它们如何帮助你在自己的项目中实现有意义的覆盖率。

要点

1. 首先重用,然后抽象

在首次设置模糊测试活动中,实现目标合约上大多数状态模糊测试的覆盖率通常是一个不错的首要目标。在创建所测试的期望属性的实现时,通常需要调用目标合约以获取状态变量值来检查前置/后置条件。

简要运行这些测试的时间可使你发现模糊测试器达到的任何局部极大值,即模糊测试器的调用总是出现回滚,导致覆盖率未能超过给定百分比。

在这种情况下,最初在此类测试的设置代码中保持冗长可以有所帮助,因为这可以可视化回滚发生的确切位置。过早地抽象出测试设置所需的共享逻辑可能会使调试变得更加困难,因为用于减少重复逻辑的函数实现可能会被多个目标路径调用。

通过从 LiquidityPool 合约的目标函数中强调这一点的例子,我们可以看到这一实践:

function liquidityPool_redeem(uint256 shares) public {
        ...
        uint256 tokenUserB4 = token.getBalance(actor, true);
        ...
}
function liquidityPool_withdraw(uint256 assets) public {
        ...
        uint256 tokenUserB4 = token.getBalance(actor, false);
        ...
}

我们看到这两个函数都定义了一个 tokenUserB4 变量,通过调用 token::getBalance 函数来加载用户的余额,通过此实现我们可以在初始覆盖率报告中看到其中一个调用是如何回滚的:

getBalance 函数回滚,阻止了覆盖扩展

由于这个逻辑在这两个函数中都是重复的,因此它可以轻松地抽象为一个简化代码且更易读的内部函数,如下所示:

function _getActorBalance(address actor) internal {
        uint256 tokenUserB4 = token.getBalance(address(actor), false);
}

但正如前面所提到的,这在最初尝试为模糊测试器扩展覆盖率时可能会使事情复杂,因为覆盖率报告将显示此功能以绿色突出,但仍然出现回滚:

这使得难以确定回滚在哪个路径中发生(是在 liquidityPool_redeem 中的调用,还是在 liquidityPool_withdraw 中),需要隔离目标函数或使用 try/catch 块,这会减慢扩展覆盖率的过程。

2. 只模糊必要的内容

在 Centrifuge 的架构中,消息在 Centrifuge 链上被处理,然后由一组接收合约(GatewayRouter)在以太坊上处理。

在此次合作的这一阶段,确定对 Centrifuge 链中消息创建者进行模糊测试实际上并没有好处,因为这在测试设置和计算上增加了显著的开销,并且在根本上会成为测试协议在以太坊侧行为的障碍。

下图显示了这些决策的汇总结果,允许以简化的方式测试系统。

尽管 GatewayRouter 合约的逻辑被排除,但通过来自 Centrifuge 的消息引入的对它们中受限函数的调用的额外随机性(例如暂停/恢复、禁用某些代币在流动性池中的使用等)仍作为目标函数保留在 GatewayMock 合约中。这有助于保持系统中可能组合的数量,确保所有组合中的不变量都保持有效,同时不会为模糊测试器引入不必要的计算开销。

3. 通过模糊设置添加额外随机性

从根本上讲,模糊测试器的目标是 LiquidityPoolPoolManagerInvestmentManager 合约,然而由于 LiquidityPool 合约可以在系统中以多个实例存在,多个基础资产支撑其相关的 TrancheTokens,因此需要一种机制来检查不同配置的此合约是否都按预期工作。

从高层次来看,问题在于如何有效地探索所有可能的 ERC20(asset) + TrancheToken + LiquidityPool 组合,而无需将每个值硬编码。

这就是模糊测试器通过创建目标函数引入随机性的地方,这些函数执行用于部署多个此类合约组合的部署步骤。这基本上复制了通常在测试 setup 函数中执行的操作,而该函数中值是确定性硬编码的,而是使用非确定性模糊值创建更多可能的组合,几乎没有额外的计算开销。

实现这一目标的方法是通过以下 deployNewTokenPoolAndTranche 函数,该函数如其所述,部署一个新的 TrancheToken 和相关的 LiquidityPool

function deployNewTokenPoolAndTranche(uint8 decimals, uint256 initialMintPerUsers) public returns (address newToken, address newTrancheToken, address newLiquidityPool)
    {
        // 注意:临时
        require(!hasDoneADeploy); // 这使得此功能在此针对 Medusa 无效
        // 意味着我们只部署一个代币、一个池、一个分级

        if (RECON_USE_SINGLE_DEPLOY) {
            hasDoneADeploy = true;
        }

        if (RECON_USE_HARDCODED_DECIMALS) {
            decimals = 18;
        }

        initialMintPerUsers = 1_000_000e18;
        // 注意结束临时

        newToken = addToken(decimals, initialMintPerUsers);
        {
            CURRENCY_ID += 1;
            poolManager_addCurrency(CURRENCY_ID, address(newToken));
        }

        {
            POOL_ID += 1;
            poolManager_addPool(POOL_ID);
            poolManager_allowInvestmentCurrency(POOL_ID, CURRENCY_ID);
        }

        {
            string memory name = "Tranche";
            string memory symbol = "T1";

            poolManager_addTranche(POOL_ID, TRANCHE_ID, name, symbol, 18, 2);
        }

        newTrancheToken = poolManager_deployTranche(POOL_ID, TRANCHE_ID);

        newLiquidityPool = poolManager_deployLiquidityPool(POOL_ID, TRANCHE_ID, address(newToken));

        // 注意:这设置了 Actors
        // 我们会通过其他方式循环它们
        // 注意:这些都是紧密耦合的
        // 第一脱耦步骤是简单地将它们全部存储为设置
        // 以便我们可以进行多次部署
        // 并进行并行检查

        // O(n)
        // 基本上开启新的部署
        // 并记录所有历史

        // O(n*m)
        // 第二步是存储排列组合
        // 这意味着我们必须在所有检查中切换所有排列组合

        liquidityPool = LiquidityPool(newLiquidityPool);
        token = ERC20(newToken);
        trancheToken = TrancheToken(newTrancheToken);
        restrictionManager = RestrictionManager(address(trancheToken.restrictionManager()));

        trancheId = TRANCHE_ID;
        poolId = POOL_ID;
        currencyId = CURRENCY_ID;

        // 注意:隐式返回
    }

这允许模糊测试器切换系统中的 actor 数量。这样的设置的意义深远,因为它不仅允许多个池和多个代币部署,还允许一些可能被忽视的场景,例如实现中池使用不同小数值的资产代币。

我们可以通过以下图表看到这如何增加可能的状态:

deployNewTokenPoolAndTranche 允许我们部署 Asset + TrancheToken + LiquidityPool 合约的多个组合。

此机制本质上通过数倍扩展了潜在错误的搜索空间,这是使用传统的 setup 函数来部署所有感兴趣合约所无法实现的。理想情况下,这创造了一个覆盖率的新维度,在其中可以更有把握地确定所有代码路径不仅在一个系统配置中达到,而且在几乎所有可能配置中都达到了。

暴露一个 switchLiquidityPool 函数可以让模糊测试器通过一次调用改变系统配置:

调用 switchLiquidityPool 改变系统配置。

结论

在本篇文章中,我们探讨了多个使编码不变量测试简单有效的策略。

在下一篇文章中,我们将看看本阶段实现的测试中出现的破坏性属性是如何评估的,以确定它们是否是有效的错误或识别出有效的问题但不可利用的情况。无法通过状态模糊测试进行测试的附加属性也被定义为不变量测试,并在第二阶段进行了评估。

感谢你阅读 Recon!免费订阅,以接收新帖子并支持我的工作。

订阅

  • 原文链接: getrecon.substack.com/p/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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