abi.encode 与 encodePacked:省掉边界会发生什么

abi.encode 和 abi.encodePacked 都输出字节,但只有理解编码边界,才能正确计算哈希和构造调用。本文用两组字符串演示拼接歧义,并讲清解码、函数选择器与签名场景的区别。

abi.encode 与 encodePacked:省掉边界会发生什么

把“north”和“star”连起来,会得到“northstar”;把“norths”和“tar”连起来,结果仍然是“northstar”。人知道它们原本是两组不同的字段,计算机如果只拿到拼接结果,却不知道分界线在哪里。

这就是理解 abi.encode 和 abi.encodePacked 最好的起点。它们都把 Solidity 值编码成字节,但保留的数据结构不同。当字节随后被用来计算签名摘要或判断记录唯一性时,省掉边界可能改变程序的含义。本文使用 Solidity 0.8.30,示例均为不涉及资金的纯函数。

编码、哈希、加密是三件事

编码是按照约定格式把数据排列成字节;解码是在知道规则的前提下恢复值。哈希把任意长度输入映射成固定长度摘要,不能从摘要直接还原原文。加密则涉及密钥与保密性,不能因为数据显示成十六进制,就称它已经被加密。

abi.encode 和 abi.encodePacked 做的是编码,keccak256 做的是哈希。一个常见表达式先编码字段,再对编码结果计算摘要。出问题时应该先比较哈希之前的字节,再判断后面的步骤,而不是一看到摘要相同就怀疑哈希算法坏了。

哈希只能区分交给它的输入。如果两组业务字段已经被编码成同一串字节,再计算多少次哈希,也无法找回丢失的边界。

标准 ABI 编码保留哪些结构

ABI 是合约接口的数据约定。标准编码对基本值使用规定的宽度和填充规则,对动态内容保存位置、长度和数据。解码方知道字段类型与顺序后,就能定位每一个值。关于钱包怎样使用 ABI 解释调用,站内已有ABI 与函数选择器入门。

例如两个短字符串即使总字节长度一样,只要第一段长度不同,标准编码的内容也会不同。它会把“north”和“star”作为两个字段,而不是只记录合起来的九个字母。

但标准 ABI 编码不是自描述格式。编码结果通常不附带字段名称与完整类型说明,解码时仍需约定类型。有些不同类型的值会得到相同字节,例如数值七按 uint8 和 uint256 单独标准编码时都被填充到同样的三十二字节。业务不能跨不同类型方案随意比较摘要。

Packed 编码省掉了什么

对于直接传入、宽度小于三十二字节的基本类型,Packed 编码会按较紧凑的形式排列;对于直接传入的 string 和 bytes,它省去长度字段,把内容接起来。因此输出经常更短,但接收方可能无法恢复原来有哪些字段。

方面abi.encodeabi.encodePacked
布局目标标准 ABI 参数编码非标准的紧凑排列
动态字段边界通过规范化的位置和长度表达直接拼接时可能丢失
通用解码可配合已知类型调用 abi.decode没有通用的 abi.decodePacked
典型用途参数、结构化数据编码明确规定紧凑字节布局的场景

不要把 Packed 理解成“对任何类型都去掉所有填充”。数组元素有自己的填充规则,结构体和嵌套数组也有限制。遇到复杂参数应直接查官方 Packed 编码规范,不要从两个字符串的例子推广出全部规则。

程序代码显示在电脑上的配图,辅助理解编码中字段边界的重要性
数据能否被正确还原,取决于发送方与接收方是否采用同一套布局约定。

一个可以复现的边界歧义

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

contract EncodingExamples {
    function compare() external pure returns (bool packedSame, bool standardSame) {
        packedSame = keccak256(abi.encodePacked("north", "star"))
            == keccak256(abi.encodePacked("norths", "tar"));
        standardSame = keccak256(abi.encode("north", "star"))
            == keccak256(abi.encode("norths", "tar"));
    }

    function sizes() external pure returns (uint256, uint256) {
        return (
            abi.encode(uint8(7), uint16(513)).length,
            abi.encodePacked(uint8(7), uint16(513)).length
        );
    }

    function roundTrip(uint256 count, string calldata label)
        external pure returns (uint256, string memory)
    {
        bytes memory encoded = abi.encode(count, label);
        return abi.decode(encoded, (uint256, string));
    }
}

compare() 应返回 (true, false)。两组 Packed 输入都变成“northstar”的 UTF-8 字节,所以哈希相同。这是编码前的字段边界丢失,不是找到两个不同字节串的 Keccak 碰撞。

sizes() 应返回 (64, 3)。数值七占一个字节,五百一十三按 uint16 表示为两个字节,Packed 结果是 0x070201;标准编码则为两个三十二字节槽。这个例子解释了长度差异,但不等于所有合约调用的总 Gas 也会按相同比例下降。

多个动态字段为什么特别需要留意

固定宽度字段可以通过预先约定的长度拆分,变长字段则不一定。当一个记录由客户名和订单备注组成,攻击者或普通输入都可能改变字段分界,让不同记录映射到相同的 Packed 字节。如果合约拿这个摘要作为“是否已使用”的唯一依据,就可能把不同业务对象混为一谈。

需要同时检查字段是否由外部控制、是否存在多个动态部分、类型和顺序是否固定。使用标准编码通常更便于保持边界;若外部协议已经规定具体紧凑格式,就应完整遵守该格式的长度、类型和解析规则,不能仅为减少几个字节临时换写法。

也不要认为插入一个分隔符就自动解决了问题。若字段本身允许包含该分隔符,歧义可能再次出现。分隔方案必须包含字符限制或转义规则,而且编码方与解析方都要执行。与其临时设计一套新规则,常见结构化数据更适合使用现成的明确格式。

什么时候能用 abi.decode 还原

上例的 roundTrip 先编码 uint256 和 string,再按照同一类型序列解码。类型顺序是协议的一部分:把它换成 string 和 uint256,并不会自动推断出原意,可能解码失败或产生错误理解。

如果手里只有 0x070201,必须事先知道是一字节整数后接两字节整数,才能还原七和五百一十三。它也可以被看成三个一字节值,或者一个三字节值。缺少格式定义时,字节本身不能告诉你哪个解释才符合业务。

同样,把完整函数调用数据直接交给只期待参数的解码器,也会发生偏移问题。完整调用通常包含开头的四字节函数选择器,参数编码从后面开始。应先明确解码对象是参数、调用数据还是返回值。

构造调用时,还需要函数选择器

abi.encode 只编码给定参数,不会自动知道你想调用哪个函数。abi.encodeWithSelector 可以在参数前加入指定选择器;abi.encodeWithSignature 根据函数签名字符串得到选择器;abi.encodeCall 根据函数引用和参数元组进行类型检查并构造调用数据。

这些函数只是准备数据,并没有自动向目标合约发起交易,也没有证明目标地址可信。具体语义可查官方编码函数说明。如果目标是调用现有接口,优先使用与该接口匹配的类型化调用方式,能减少手工拼字符串时的错误。

开发人员共同查看电脑代码的配图,说明前后端应核对编码测试向量
排查摘要或签名不一致时,先对齐字段类型、顺序和编码后的原始字节。

签名场景还要定义消息属于谁

就算字段编码没有歧义,一份消息仍可能缺少业务范围。某个地址签过“允许数量十”,到底针对哪个应用、哪个合约、哪条链、哪次操作?编码正确不代表授权范围完整。完整设计还要考虑域信息、唯一序号、有效期和实际验证逻辑。

EIP-712提供结构化数据的类型化哈希与签名方案,并明确它本身不包含完整的重放保护。实现时应遵守标准及所用库的约定,不能只把几个字段套进 abi.encode 就声称已经实现 EIP-712。本文的比较示例也不应直接拿来当生产签名协议。

前后端不一致,按这个顺序排查

  1. 字段值:是否有隐藏空格、不同大小写、不同小数单位或遗漏字段。
  2. 类型:uint8 与 uint256、bytes32 与 bytes、文本与十六进制字节是否一致。
  3. 顺序:两端传入的参数顺序与嵌套结构是否完全相同。
  4. 编码方式:一端是否使用标准 ABI,另一端误用了 Packed。
  5. 哈希输入:是否对真实字节计算,而不是对它的十六进制文字再编码。
  6. 签名外层:是否还存在消息前缀、域分隔符或其他签名标准步骤。

为一组固定输入保存编码字节、长度和最终摘要,可以让两端共享同一份测试向量。再补空字符串、非 ASCII 文本、边界数值和两段字符串重新分界的例子,比只测试一组正常输入更容易暴露协议理解差异。

记住五个结论

  • 编码规定字节布局,哈希生成摘要,两者都不等于加密。
  • 标准 ABI 可以依据已知类型解码,但不会自动携带全部类型含义。
  • Packed 的紧凑表示可能丢掉动态字段边界。
  • 相同编码字节产生相同摘要,不代表破解了哈希算法。
  • 构造调用与签名时,还要核对选择器、类型、域信息及协议要求。

下次看到 keccak256(abi.encodePacked(...)),先问每个参数的边界是否能被唯一解释,再看这串摘要被拿去做什么。


本文为链上指南原创技术科普,不构成任何投资建议。代码用于演示编码语义,生产系统应使用经过验证的协议实现并完成相应测试。配图来自 Unsplash:Kevin Ache、Emile Perron、Compagnons;图片用于辅助阅读。