Huff实战:编写测试极致效率数学模块

概述

读者可前往 我的博客 获得更好的阅读体验。

Huff 是 EVM 专用语言,与 Solidity 不同,Huff 是面向底层的语言,可以类比与汇编语言。这意味着开发者可以直接操作栈、内存和存储等内容,但另一方面,这些底层操作往往没有安全保证,这需要开发者更加仔细的审计和测试代码。本文章由于涉及大量 EVM 底层操作,希望读者阅读过以下文章:

  1. Foundry教程:使用多种方式编写可升级的智能合约(上)
  2. Foundry教程:使用多种方式编写可升级的智能合约(下)
  3. EVM底层探索:字节码级分析最小化代理标准EIP1167

这些文章都是关于代理合约话题的,这是因为代理合约往往需要使用 yul 汇编语言实现核心功能,所以大量涉及 EVM 底层内容。

当然,为了保证所编写的底层合约的安全性,也希望读者阅读过 Foundry 高级测试: Fuzz、Invariant与形式化证明 ,该篇博客主要介绍了一些高级测试技巧。

由于 Huff 使用了大量底层操作码,请读者阅读过程中一直开启 evm.codes ,本文不再给出每个操作码对应的链接。

本文主要准备使用 huff 实现一个高效率的数学模块,由于计算机底层数学操作大量使用位移等底层计算原语,使用 Solidity 会大量增加合约的 gas 消耗,本文的目标是构造一个在 gas 方面达到极致的合约。但需要注意极致的优化意味着可读性的大幅度降低。

笔者本人并不是算法领域的工程师,但笔者最近阅读了 《算法心得:高效算法的奥秘》 一书,此书包含大量依赖二进制数据操作的黑魔法,本文大部分实现都来自此书。

本文所有代码位于 Github 仓库 中,读者可以参考。

为什么不选择使用 huff 实现 ERC-20 等常见合约?原因在于 huff 本身的表达能力较差,官方实现的 ERC20 有近 500 行。

EVM 基础

对于 EVM 整体架构,我们可以通过下图表示:

EVM architecture

我们可以看到可变的数据只有:

  • calldata 请求合约调用的数据
  • Gas 交易的 gas 费用
  • PC 程序计数器,记录当前运行的代码的位置
  • stack 栈,用于存放计算所需要的参数和执行计算操作
  • memory 内存

而实际上,我们主要操作 stack 栈和 memory 内存,而 calldata 区域在合约运行时是只读的。

当然,我们也可以修改 storage 存储。

一笔交易触发的 EVM 运行流程如下:

EVM Run

下图展示了 stack 栈的作用:

stack use

栈的最大深度为 1024 个元素,每个元素位长为 256 ,我们可以使用 PUSH 向栈内推入元素,也可以使用 POP 弹出元素,同时也可以对栈内元素进行操作,如通过 ADD 实现栈内元素的相加。上图即展示了栈内元素相加的情况。

几乎所有的 EVM 操作码都会对栈进行操作,evm.codes 中给出了每个操作码所需要的栈元素,即 Stack Input 一列。

下图展示了 memory 内存的基础情况:

EVM Memory

内存是一个可寻址的线性空间,一般情况下,我们使用 MSTORE 操作码向地址内写入数据,使用 MLOAD 操作码读取数据,值得注意的是,这两个操作码仅支持 256 bit 数据的整体写入和读取。上图展示了一种较常见的情况,即将栈内的结果写入内存中。

在 EVM 中,虽然不存在内存溢出情况,但这不意味着我们不需要进行垃圾回收,因为随着写入的偏移增加,gas 消耗也随之增长。

下图展示了 storage 存储的一般情况:

Contract Storgae

在 EVM 内,存储是一个 KV 数据库(或理解为词典数据类型),每个数据由 256 bit 长度的键与 256 bit 长度的值构成。一般使用 SSTORE 进行存储,使用 SLOAD 进行读取。值得注意的是,操作存储是一项开销极大的操作。

最后,我们介绍没有在图中展示的 return data ,该内容用于对合约调用者返回信息,一般使用 RETURN 操作码返回操作成功后的数据,使用 REVERT 操作码返回报错信息。接受方可以使用 RETURNDATACOPY 等操作码进行 return data 的数据读取。

上述内容仅是对 EVM 进行了初步介绍,可以保证读者基本可以完成本文内容的阅读,如果读者有时间,请阅读 About the EVM

更多参考资料可以使用 EVM tag 在我的 阅读数据库 里搜索,也欢迎大家订阅 我的频道 以获取最新的资料。

EVM Read

环境配置

huffc 安装

由于 foundry 并没有原生支持 huff 语言,所以我们需要单独安装 huff 的编译器 huffc ,由于 huffc 也是使用 Rust 编写的程序,所以安装较为简单。命令如下:

curl -L get.huff.sh | bash
source .bashrc
huffup --version nightly

最后,我们运行 huffc --version 可以获得版本输出。

在安装过程中,会显示 /root/.huff/bin/huffup: line 25: yarn: command not found 输出,该输出不是要求安装 yarn ,而是表示该用户环境内没有 yarn 版本的 huffc (早期的 huffc 是使用 javascript 编写的,该版本已被废弃)。

项目初始化

运行以下命令使用 huff-project-template 模板建立项目:

forge init --template https://github.com/huff-language/huff-project-template huffLearn

接下来就进入了正式的合约编程环节了,读者可以选择使用自己喜欢的编辑器,如 vscodesublime ,前者需要搭配 vscode-huff 插件,实现了很多有用的功能,而后者需要搭配 hufflime 插件,仅有语法高亮的功能。

常数表

为方便读者阅读,此处我们给出一些转换数据:

  1. 1 byte = 8 bits
  2. 1 byte = 2 hex
  3. 1 hex = 4 bits

其中,hex 表示一个 16 进制字符。

接下来,读者可以进行一些简单的训练:

  1. 已知 EVM 内存位长为 256 bits,请计算对应的 byte
  2. 已知某函数选择器为 0x8cc5ce99 ,请计算对应的 bits

答案为:

  1. 32 bytes (此处使用的 bytes 仅表示其为 byte 的复数形式)
  2. 32 bits

基础运算

在本节中,我们主要介绍以下内容:

  1. 可溢出加法
  2. 不可溢出加法
  3. 不可溢出乘法
  4. 前导 0 计数算法
  5. log2 算法
  6. 开方算法

正如前文所言,在本文中,我们将大量使用二进制操作黑魔法,以追求极致的 gas 效率。在本文中,我们不会分析算法的安全性问题,原因如下:

  1. 本文介绍的算法均来自传统计算机领域,属于底层算法,出现安全问题的概率较小
  2. 严格证明算法的正确性需要使用 z3 等求解器形式化证明,为保证文章的专题性,我们不会进行讨论,但可能会在下一篇文章内对其进行分析。

可溢出加法

可溢出加法并没有什么值得讨论的算法问题,本节主要是为了读者可以更快适应 huff 编程。

本节正式进入 huff 编程,我们将展示一些简单的基础的 huff 代码,请读者在 src 文件夹内创建 HuffMath.huff 文件,写入以下内容:

#define function addNumbers(uint256,uint256) nonpayable returns (uint256)

#define macro NON_SAFE_ADD() = takes (0) returns (0) {
	0x04 calldataload	// [num1]
	0x24 calldataload	// [num2, num1]
	add 			// [result]
	0x00 mstore
	0x20 0x00 return
}

#define macro MAIN() = takes (0) returns (0) {
	0x00 calldataload 0xE0 shr

	dup1 __FUNC_SIG(addNumbers) eq addNumbers jumpi

	addNumbers:
		NON_SAFE_ADD()
}

上述 huff 代码构造了一个用于加法的函数。在文件开始,我们需要定义该合约所拥有的函数的 ABI 接口,基本与 solidity 的接口写法一致,但此处增加了 nonpayable 标识符表示该函数不操作 ETH 原生资产,且此处无需表明函数的 public 等属性。

然后,我们定义具体的函数 NON_SAFE_ADD ,此处使用了 macro 标识,在 huff 中,macro 可以理解为函数的意思。然后,我们使用 takes 规定该函数从栈内消耗的元素数量。此处,我们设计的 NON_SAFE_ADD 函数并不需要消耗栈内元素,所以此处使用了 takes (0) 作为定义。

returns 表示函数运行结束后,栈内剩余的元素数量,由于此处 NON_SAFE_ADD 运行结束后直接将运行结果返回,所以使用 returns (0) 表示函数运行结束后栈内无剩余元素。

接下来,我们进入函数体的定义。我们可以看到每行末尾都有一个形如 // [num1] 的注释,这些注释只是方便开发者了解当前栈内的元素情况,在编译时没有实际意义。

NON_SAFE_ADD 可以分为三部分:

  1. 将参数写入栈内,使用 calldataload 操作符
  2. 具体计算环节,使用 add 操作符
  3. 返回环节,使用 mstorereturn 操作符

关于 calldata 的具体构成,我们在此处不进行详细讨论,读者可以参考 Solidity 文档的 Contract ABI Specification 一节,或者参考 Reversing The EVM: Raw CalldataABI Encoding Deep Dive 等文章。

可能有读者好奇作为 huff 语言开发,为什么要看 solidity 文档的解释?这是因为 solidity 的合约 ABI 规范已经成为了业内标准,包括 vyper 在内的智能合约语言都符合 solidity 规范。

calldata 导入后,我们获得了形如 [num2, num1] 的栈结构,我们使用 add 操作栈元素,操作后获得 [num1+num2] 的栈结构。接下来我们使用 0x00 mstore 语句,此语句可以分解为将 0x00 推入栈内,运行 mstore 操作码。mstore 的具体功能是将栈内元素放置到内存中,具体功能请参考 evm codes

在 huff 中,所有写出的数字都会被推入栈内,无需手动调用 PUSH 操作码

完成 mstore 操作后,栈内元素均被清空,接下来,我们需要进行数据返回操作,使用 0x20 0x00 向栈内推入 0x200x00 元素,调用 return 从内存中返回值。其中,0x00 指明 offset ,即返回内容在内存中的起始位置,而 0x20 指明 size,即返回内容的长度。

以上,我们就完成了一个简单的函数编写。

仅有函数是不够的,我们需要定义一个主函数进行代码调用分发,该函数是字节码的开始,主要功能是根据 calldata 的函数选择器部分选择对应的函数运行。为方便读者阅读,我们再次展示此代码:

#define macro MAIN() = takes (0) returns (0) {
	0x00 calldataload 0xE0 shr

	dup1 __FUNC_SIG(addNumbers) eq addNumbers jumpi

	addNumbers:
		NON_SAFE_ADD()
}

此处我们将使用 0x00 calldataload 将数据导入到栈内,该过程可被分解为 0x00 推入栈内,然后运行 calldataload 操作码。然后,我们使用 0xE0 shr 对栈内数据进行右移 0xe0 位(即右移224位)操作,此处栈内仅剩余长度为 32 位的选择器字段。然后,我们使用 dup1 复制该元素,此时栈结构 [selector, selector] (此处的 selector 指处理好的待匹配的函数选择器)。然后,我们将代码中提前计算好的函数选择器推入栈内,获得 [0x0f3d0204, selector, selector] (此处使用 0x0f3d0204addNumber 的函数选择器),进行 eq 相等判断。如果相等,我们获得 [1, selector] 栈结构。然后,我们看到了 addNumber jumpi 两个操作码,我们可以视两者为一个整体,等同于 C 语言或者 golang 中的 goto label 结构,但 jumpi 是带条件跳转,如果栈内第一个元素不是 true 则不会跳转。

上述流程听上去较为复杂,原因在于使用了纯汇编,使用高等语言表示如下:

function_selector = calldata >> 224
if (function_selector == sig(addNumbers)) {
   
	goto label
} else {
   
	...
}

上述代码中 sig 表示选择器生成函数,即使用 addNumbers 定义生成对应的 4 bytes 无符号整数。读者可以看到,我们使用了 jumpi 实现了 if 跳转。

此过程使用 switch 结构表达更为合适,但此处仅有一个函数选择器,使用 if 语句也可。

最后,我们定义了:

addNumbers:
	NON_SAFE_ADD()

为上文的跳转提供目的地。

如果读者了解过编程语言的历史就知道,goto 语句是早期语言的一大争议点,如今,大部分高级语言都删除了此语句。但在 huff 中,为了实现 if 逻辑,我们不得不使用 jumpi 语句。

读者有可能发现了使用上述方法进行函数选择器匹配的时间复杂度为 O(n) ,对于某些大型项目而言,此时间复杂度是无法容忍的,所以还有一种更加强大的基于二分搜索的方法,如果感兴趣,读者可以阅读 Constant Gas Function Dispatchers in the EVM 文章。

编写完上述函数后,我们需要对其进行测试,请读者建立 test\HuffMath.t.sol 合约,键入以下内容:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

import "foundry-huff/HuffDeployer.sol";
import "forge-std/Test.sol";

contract mathTest is Test {
	HuffMath public huffmath;

    function setUp() public {
        huffmath = HuffMath(HuffDeployer.deploy("HuffMath"));
    }

    function test_add() public {
    	uint256 result = huffmath.addNumbers(1, 2);
    	assertEq(result, 3);
    }
}

interface HuffMath {
    function addNumbers(uint256, uint256) external pure returns (uint256);;
}

总体来说,测试 huff 合约比较简单,只是改变了合约部署方法,使用 HuffDeployer.deploy 部署,而不是简单的 new ,以及需要手动编写接口。

需要注意的是,huff 合约的测试是慢于 foundrysolidity 合约的测试的,后者有着 foundry 开发者提供的各种优化,而前者并没有这些优化,导致 huff 合约测试速度较慢。

可能有读者好奇,huff 如何兼容 foundry 的?关键在于 HuffDeployer 合约,该合约使用 foundry 的知名高级特性 ffi 。简单来说,foundry 允许用户通过 ffi 直接调用命令行工具。

不可溢出加法

本节主要讨论安全加法,即不可溢出加法。一旦加法结果溢出,则触发错误,进行 revert 回滚操作。

在具体实现前,我们首先需要知道如何判断加法是否溢出。对于无符号数的加法,我们可以通过简单的判断加法结果是否小于其中任何一个操作数即可。

对于带符号加法,我们很难判断是否溢出,且带符号加法在智能合约领域较为少见,故本文不予讨论。

接下来,我们开始构造该函数。

首先在 HuffMath.huff 的头部定义函数:

#define function safeAdd(uint256,uint256) nonpayable returns (uint256)

首先,我们使用常规步骤导入两个加数,代码如下:

	0x04 calldataload	// [num1]
	0x24 calldataload	// [num2, num1]

可能有读者希望在此处使用 add 操作符,但我们发现直接使用 add 后,获得 [result] 结构的栈。(此处的 resultnum1 + num2,为方便而记为 result)。然后,我们发现无法进行后续步骤,因为我们还需要进行加法结果与加数的比较。

所以,在此处,我们使用 dup2num1 复制一份到栈顶,获得 [num1, num2, num1] 的栈结构。然后运行 add 操作码,获得 [result, num1] 栈结构。可能又有读者准备直接使用 lt 操作符,以判断 result < num1 是否成立,但需要注意一旦运行 lt 操作码,则会直接消耗 resultnum1 两个栈元素,使得

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
下面是mat2huffhuff2mat函数的代码实现,以及一个简单的示例: ```matlab function huff = mat2huff(mat) % MAT2HUFF converts a matrix into a Huffman encoding % huff = mat2huff(mat) % mat: input matrix % huff: output Huffman encoding % Calculate symbol probabilities symbols = unique(mat); prob = histc(mat(:), symbols) / numel(mat); % Build Huffman tree [~, ~, code] = hufftree(prob); % Encode matrix using Huffman code huff = cell(size(mat)); for i = 1:numel(mat) idx = symbols == mat(i); huff{i} = code{idx}; end huff = cat(1, huff{:}); end function mat = huff2mat(huff, symbols) % HUFF2MAT decodes a Huffman encoding into a matrix % mat = huff2mat(huff, symbols) % huff: input Huffman encoding % symbols: vector of symbols used to generate Huffman code % mat: output matrix % Build Huffman tree prob = histc(symbols(:), symbols) / numel(symbols); [~, idx, ~] = hufftree(prob); % Decode Huffman code using tree mat = zeros(size(huff)); currnode = idx(end); for i = 1:numel(huff) if huff(i) == '0' currnode = idx(currnode, 1); else currnode = idx(currnode, 2); end if isempty(idx(currnode, 1)) && isempty(idx(currnode, 2)) mat(i) = symbols(currnode); currnode = idx(end); end end mat = reshape(mat, size(huff, 1), []); end % Example usage mat = [1 2 3; 3 2 1]; huff = mat2huff(mat); mat2 = huff2mat(huff, unique(mat)); assert(isequal(mat, mat2)); ``` 在这个示例中,我们将一个2x3的矩阵编码为Huffman编码,然后解码回原始矩阵。mat2huff函数将矩阵转换为Huffman编码,huff2mat函数将Huffman编码解码为矩阵。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WongSSH

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值