Sway 数据编码与 ABI 编解码机制深度解析:合约调用、脚本、谓词、日志与 Configurables 的字节级真相
【免费下载链接】sway🌴 Empowering everyone to build reliable and efficient smart contracts.项目地址: https://gitcode.com/GitHub_Trending/sw/sway
Sway(Fuel 链上的智能合约语言)并不允许调用方直接把参数指针传递给合约,而是引入了一套完整的AbiEncode/AbiDecode编码体系,让所有跨合约边界的数据都以显式的字节形式交换。本文基于 docs/slides/encoding.md 与 docs/slides/trivial_encoding.md 两份内部技术讲稿,结合 sway-lib-std/src/codec.sw 与 sway-core 编译器源码,完整还原 Sway 编码体系的设计动机、调用双方的内存布局、各应用场景(合约、脚本、谓词、日志、configurables)的编译期展开过程,以及“平凡编码(trivially encodable)”优化背后的零拷贝思想。读完本文,你将能看懂 Sway 编译器生成代码中encode/abi_decode/__contract_call的全部细节,并能在实际合约开发中主动利用平凡编码降低二进制体积与 Gas 消耗。
一、为什么 Sway 需要一套编码体系?
合约 ABI(应用二进制接口)只定义了“交换什么类型”,却没有定义“这些类型如何在内存中被传递”。例如下面的 ABI 声明:
abi MyContract { fn some_method(arg: Vec<u64>); }它只约定了some_method接收一个Vec<u64>,但没有约定Vec在内存中的字节布局。如果调用方与合约实现方各自使用不同版本的stdlib,对Vec内部字段的顺序定义不同,双方就会互相“读错”数据。
编码体系的第一个作用,就是消除这种 ABI 不稳定(ABI instability)。它强制每个类型通过实现AbiEncode/AbiDecode特征来显式声明“自己如何被编码、如何被解码”:
impl<T> AbiEncode for Vec<T> where T: AbiEncode { fn abi_encode(self, buffer: Buffer) -> Buffer { ... } } impl<T> AbiDecode for Vec<T> where T: AbiDecode { fn abi_decode(ref mut buffer: BufferReader) -> Vec<T> { ... } }为了改善开发体验,编译器会自动为所有不含指针(pointer)的类型实现AbiEncode/AbiDecode——即结构体、元组、数组等普通数据类型无需开发者手写编解码逻辑。
除此之外,编码体系还要解决另外两个关键问题:返回值降级(Return Value Demotion)与别名攻击(Aliasing),详见本文第三节。
二、合约调用两侧的完整数据流
2.1 调用方视角(Caller POV)
当你写出下面这段代码时:
let contract = abi(TestContract, CONTRACT_ID); contract.some_method(0);编译器会先把它脱糖(desugar)为对标准库std::codec::contract_call的调用:
std::codec::contract_call( CONTRACT_ID, "some_method", (0,), )在 sway-lib-std/src/codec.sw 中,contract_call的实际实现正是按照“编码 → 打包参数 → 调用内建函数 → 解码返回值”的流程展开的:
pub fn contract_call<T, TArgs>( contract_id: b256, method_name: raw_slice, args: TArgs, coins: u64, asset_id: b256, gas: u64, ) -> T where T: AbiDecode, TArgs: AbiEncode, { let second_parameter = encode(args); let params = ( contract_id, asm(a: method_name.ptr()) { a: u64 }, asm(a: second_parameter.ptr()) { a: u64 }, ); __contract_call(¶ms, coins, asset_id, gas); let ptr = asm() { ret: raw_ptr }; decode_from_raw_ptr::<T>(ptr) }对应讲稿中的展开形式即:
let first_parameter = encode("some_method"); let second_parameter = encode((0,)); let params = encode(( CONTRACT_ID, first_parameter.ptr(), second_parameter.ptr(), )); let (ptr, len) = __contract_call(params.ptr(), coins, asset_id, gas); let mut buffer = BufferReader::from_parts(ptr, len); T::abi_decode(buffer)其中:
first_parameter:对方法名字符串"some_method"编码得到的字节;second_parameter:对实参元组(0,)编码得到的字节;params:把合约 ID(32 字节)、方法名指针、实参指针打包后的参数区;coins/asset_id/gas:随调用携带的代币数量、资产 ID 与 Gas 上限。
最终,__contract_call会被编译为一条fuelVM 的call指令,其内存布局如下($hp指向堆顶,$ssp与$sp是栈指针):
$hp │ │ ┌────────────────────────────┐ ▼ │ │ ┌──────────────┬──────┴──────┬──────────────┬──────▼────────┬───────────────┐ │ │ │ │ │ │ HEAP │ CONTRACT_ID │ method name │ method args │ encoded bytes │ encoded bytes │ │ 32 bytes │ param1 │ param2 │ │ │ └──────▲───────┴─────────────┴──────┬───────┴───────────────┴───────▲───────┘ │ │ │ │ └───────────────────────────────┘ │ ... │ call $ra:ptr $rb:u64 $rc:ptr $rd:u64 ... coins │ gas │ ┌▼─────────────┐ │ │ STACK .................... │ ASSET_ID │ │ 32 bytes │ └──────────────┘ ▲ ▲ │ │ │ │ $ssp $sp可以看到,被调用合约可以访问到的只是堆上的一段“编码后”字节,以及栈上由调用方维护的ASSET_ID(32 字节),而永远无法拿到调用方内存中的真实对象指针——这正是下一节要讲的安全设计。
2.2 被调用方视角(Contract being called POV)
假设目标合约的实现是:
impl TestContract for Contract { fn some_method(qty: u64) { ... } }编译器会把合约入口脱糖为一个统一的__entry函数:
pub fn __entry() { let method_name = std::codec::decode_first_param::<str>(); if method_name == "some_method" { let mut buffer = std::codec::BufferReader::from_second_parameter(); let args: (u64,) = buffer.decode::<(u64,)>(); let result: () = __contract_entry_some_method(args.0); let result: raw_slice = encode::<()>(result); __contract_ret(result.ptr(), result.len::<u8>()); } __revert(123); }两个关键说明:
__contract_entry_some_method就是原始some_method的编译产物,保持原样;__contract_ret是一个“立即返回当前上下文”的特殊函数,因此生成代码中不再需要return。
这段脱糖逻辑与标准库中的实际 API 完全对应:decode_first_param与decode_second_param分别从调用帧(call frame)的第 73 与 74 个字偏移处读取参数指针,见 sway-lib-std/src/codec.sw 与其使用的BufferReader::from_first_parameter/from_second_parameter(第 69–96 行,其中FIRST_PARAMETER_OFFSET: u64 = 73、SECOND_PARAMETER_OFFSET: u64 = 74)。
被调用方在真正执行合约方法之前,内存布局如下——注意栈上的param1 @ 73 words、param2 @ 74 words与上面偏移量逐一吻合:
$hp | | +----------------------------+ v | | +--------------+------+------+--------------+------v--------+---------------+ | | | | | | HEAP | CONTRACT_ID | method name | method args | encoded bytes | encoded bytes | | 32 bytes | param1 | param2 | | | +--------------+-------------+------+-------+------^--------+-------^--^----+ | | | | +--------------+----------------+ | | | +-------------------------+ | | | | +-------------------------------------+ | | +--------------+--------+-------+--------+ | | param1 param2 | STACK | ASSET_ID | @ 73 words @ 74 words | | 32 bytes | offset offset | +--------------+-------------------------+ ^ call frame metadata ^ | | | | $fp $ssp/$sp2.3 编译器侧的佐证
在编译器源码中,__contract_call与__contract_ret是作为内建函数(intrinsic)被类型检查的,见 sway-core/src/semantic_analysis/ast_node/expression/intrinsic_function.rs;而方法调用若指向合约,会在类型化语法树中携带ContractCallParams(含可选的 4 字节func_selector),见 sway-core/src/language/ty/expression/contract.rs。从源码结构可以推断:方法名、实参是否编码、如何编码,完全由编译器在脱糖阶段决定,合约作者无需手动干预。
三、为什么不能直接传指针?——编码的三大动机
讲稿用了一个专门章节“Interlude: Why even encode the data?”来回答“为什么不把所有参数直接传递过去”,核心原因有三条:
3.1 规避 ABI 不稳定(ABI instability)
API 只定义类型,不定义字节布局。设想调用方与被调用方都用std-lib v1编译,Vec<T>的内存结构是:
struct Vec<T> { pointer: raw_ptr, cap: u64, len: u64, }而合约实现方随后升级到std-lib v2,不知何故把字段顺序改成了:
struct Vec<T> { len: u64, pointer: raw_ptr, cap: u64, }那么调用方从此再也无法正确调用该合约——这就是“ABI Hell”:
| BEFORE | AFTER | +-----> { 0x...., 16, 4 } <----+ | +-----> { 0x...., 16, 4 } <---+ | | | | | | | | | | { pointer, capacity, len } { pointer, capacity, len } | { pointer, capacity, len } { len, pointer, capacity } | What What | What What CALLER CALLEE | CALLER CALLEE sends sees | sends sees Vec @ std-lib v1 Vec @ std-lib v1 | Vec @ std-lib v1 Vec @ std-lib v2再进一步想象:库版本、编译器版本、编译参数、编译器优化都可能改变字节布局。这些变化不仅会悄无声息地破坏合约调用,还会破坏所有消费这些字节的下游——索引器(indexers)、SDK、receipt 解析器等。而 Sway 的编码方案强制每个类型显式声明编解码方式,从根上规避了这一类问题。
3.2 返回值降级(Return Value Demotion)
考虑一个返回Vec<u64>的合约方法:
impl TestContract for Contract { fn some_method() -> Vec<u64> { Vec::new() } }实际上它会被编译成类似下面的形式——这被称为“Return Value Demotion”:
fn __contract_entry_some_method(return_value: &mut Vec<u64>) { *return_value = Vec::new(); }问题在于:return_value会指向被调用方(callee)的内存区域,而合约无法写入对方内存。这要求return_value指向由合约自己分配的某处内存,再交由被调用方复制过去。编码机制让返回值也以字节形式回传,从而绕开了“跨内存空间写指针”的难题。
3.3 别名攻击(Aliasing)与安全边界
如果绕过编码、允许调用方直接把指针传给合约,会立刻产生安全问题:恶意调用方可以构造别名(aliased)数据结构来欺骗合约。例如:
impl TestContract for Contract { fn some_method(v: Vec<u64>) { let some_value1 = do_something(&v); // do some_thing that allocate memory let some_value2 = do_something(&v); } }恶意调用方可以构造一个指针指向合约自身内存的Vec。上面代码中就可能出现some_value1 != some_value2这种完全反直觉的结果,从而让调用方有机可乘地攻击合约。因此 Sway 的编码方案不允许传递指针、引用等——你永远只能传递数据本身。
四、编码体系覆盖的全部场景
除合约调用外,Sway 的编码体系还覆盖以下四个场景,讲稿将它们统称为“What we have left”:
4.1 脚本(Scripts)与谓词(Predicates)
script和predicate的main函数可以带参数:
fn main(v: u64) -> bool { ... }两种情况下,编译器都会脱糖为类似下面的入口:
pub fn __entry() -> raw_slice { let args: (u64,) = decode_script_data::<(u64,)>(); // or decode_predicate_data let result: u64 = main(args.0); encode::<u64>(result) }标准库中的对应实现分别是decode_script_data(通过__gtf::<raw_ptr>(0, 0xA)读取脚本数据区)与decode_predicate_data(通过gm指令取得验证谓词索引,再根据输入类型读取谓词数据),见 sway-lib-std/src/codec.sw 及BufferReader::from_script_data/from_predicate_data(第 98–119 行)。
4.2 日志与回执(Logs / Receipts)
调用std::log时,其参数同样会被编码。因此:
log(1);会被脱糖为:
__log(encode(1));在 sway-lib-std/src/logging.sw 中,log<T>的实现是__log::<T>(value),在开启新编码(experimental_new_encoding = true)时约束T: AbiEncode——也就是说日志内容会以编码后的字节形式出现在回执(receipt)中,链下解析方只要遵循同一套编解码规则即可读取。
4.3 Configurables(可配置常量)
Configurables 稍微复杂一些:它们的初始化值在编译期就被求值。例如:
configurable { SOMETHING: u64 = 1, }上面这个例子会求值为1。编译器随后调用encode(1)并把结果追加到二进制文件的末尾。
为了允许 SDK 在部署前替换该值,编译器会在生成的ABI JSON中写入一条记录,包含该缓冲区在二进制中的偏移:
{ "name": "SOMETHING", "configurableType": { ... }, "offset": 7104 },那么 configurables 是如何“解码”的呢?这部分由编译器自动完成。例如:
configurable { SOMETHING: u64 = 1 } fn main() -> u64 { SOMETHING }会被脱糖为类似下面的样子(注意:这不是合法的 Sway 语法,仅用于表达编译器的行为):
const SOMETHING: u64; fn __entry() -> raw_slice { std::codec::abi_decode_in_place(&mut SOMETHING, 7104, 8); encode(main()) } fn main() -> u64 { SOMETHING }其中abi_decode_in_place(sway-lib-std/src/codec.sw)会直接在原位置解码:若类型平凡可解码则用一条mcp内存复制指令完成,否则先解码到临时变量再复制到目标地址。这里传入的7104正是 ABI JSON 中记录的offset,8是u64的字节长度。
五、平凡可编码 / 可解码类型(Trivially Encodable/Decodable Types)
编码和解码并非免费:它们会增加二进制体积,也会增加 Gas 消耗。除非——
- 参数是平凡可编码的(trivially encodable),且
- 返回类型是平凡可解码的(trivially decodable)。
5.1 定义:内存表示 == 编码表示
一个类型是“平凡可编码/可解码”的,当且仅当它的运行时内存表示与其编码表示完全一致(有一个下文会讲到的例外)。这与“零拷贝反序列化(zero-copy deserialization)”是同一个思想:
零拷贝反序列化是一种允许直接从序列化字节缓冲区访问数据的技术,无需分配新内存或将数据复制到独立结构中。这是通过保证序列化格式的内存布局与目标数据结构的运行时内存表示一致来实现的,从而可以直接通过类型转换或指针偏移访问字段,而无需任何解析或转换工作。
例 1:对齐的元组。一个全由u64组成的元组,运行时与编码后完全相同:
fn main ( _: (1u64, 2u64, 3u64) ) { ... }运行时表示:
------------------------------------- | 00 ... 01 | 00 ... 02 | 00 ... 03 | -------------------------------------编码表示:
------------------------------------- | 00 ... 01 | 00 ... 02 | 00 ... 03 | -------------------------------------例 2:出现填充(padding)的元组。一旦混入u8,运行时为了对齐会插入 padding,编码表示则不含 padding,二者不再一致:
fn main ( _: (1u8, 2u64, 3u64) ) { ... }运行时表示:
------------------------------------------ | 01 | 00 ... 00 | 00 ... 02 | 00 ... 03 | ------------------------------------------ ^^^^^^^^^ padding编码表示:
------------------------------ | 01 | 00 ... 02 | 00 ... 03 | ------------------------------由于第二个例子的内存表示与编码表示存在错配,二进制体积的代价一目了然。讲稿给出了同一程序的两次编译对比:
平凡场景(136 字节):
Finished release [optimized + fuel] target(s) [136 B] in 0.90s非平凡场景(208 字节):
Finished release [optimized + fuel] target(s) [208 B] in 0.89s5.2 底层实现:is_encode_trivial/is_decode_trivial
平凡性的判定与快路径(fast path)由标准库统一实现:
pub trait AbiEncode { fn is_encode_trivial() -> bool; fn abi_encode(self, buffer: Buffer) -> Buffer; } pub trait AbiDecode { fn is_decode_trivial() -> bool; fn abi_decode(ref mut buffer: BufferReader) -> Self; } pub fn encode<T>(item: T) -> raw_slice where T: AbiEncode { if T::is_encode_trivial() { ... } else { ... } } pub fn abi_decode<T>(data: raw_slice) -> T where T: AbiDecode { if T::is_decode_trivial() { ... } else { ... } }实际的encode实现(sway-lib-std/src/codec.sw)在平凡路径上直接aloc分配、mcp复制一份原样的字节并包装成raw_slice,连一次逐字段编解码循环都省掉了;abi_decode(第 1759–1775 行)的平凡路径同样只需mcp拷贝即可。
5.3 各基础类型的平凡性一览
从 sway-lib-std/src/codec.sw 的AbiEncode实现可以确认以下结论(判定依据is_encode_trivial()的返回值):
| 类型 | 平凡编码 | 说明 |
|---|---|---|
u64、u8 | ✅ | u64按 8 字节、u8按 1 字节,无 padding 问题 |
u32、u16 | ❌ | 编码时需消除对齐差异 |
bool | ✅(编码)/ ❌(解码) | 见下节 Trap Representations |
b256、u256 | ✅ | 定长且对齐 |
str(动态字符串) | ❌ | 需编码长度信息 |
str[N] | 视情况 | experimental_str_array_no_padding = false时不平凡(第 292–303 行);开启= true后平凡(第 305–316 行) |
raw_slice | ❌ | 引用类型 |
数组[T; N] | 继承T | 见第 331–349 行 |
| 元组 | 逐元素判断 | 元组平凡性 =__mem_repr_eq::<Self>("runtime", "encoding")且每个元素平凡(第 362 行起) |
单元类型() | ✅ | 空编码 |
元组平凡性判定的关键是内建函数__mem_repr_eq——它在编译期比较某个类型的“运行时表示”与“编码表示”是否等价,见 sway-core/src/semantic_analysis/ast_node/expression/intrinsic_function.rs 及其type_check_mem_repr_eq实现。这保证了“内存布局是否与编码布局一致”由编译器静态判定,而非运行时猜测。
5.4 Trap Representations:布局相同也不一定安全解码
Trap representation(陷阱表示)是一种“对该类型而言无效的位模式”。
有些类型的内存布局与编码布局一致,却仍然不能安全地平凡解码:
bool:平凡可编码,但不平凡可解码——任何非 0/1 的位模式都是非法bool,直接读取会得到陷阱值;- 枚举(enums):因为带有“隐藏的判别值(discriminant)”,且该判别值只接受特定取值:
enum A { A: ..., B: ..., C: ... }-------------------------- | 0000000000000000 | ... | -------------------------- ^^^^^^^^^^^^^^^^ Discriminant (8 bytes)如果开发者愿意承担风险、自行处理非法表示,可以强制某个类型按平凡解码处理。讲稿给出的通用包装类型是:
pub struct TriviallyDecodable<T> { value: T } impl<T> AbiDecode for TriviallyDecodable<T> { fn is_decode_trivial() -> bool { true } fn abi_decode(ref mut buffer: BufferReader) -> Self { let value = T::abi_decode(buffer); Self { value } } } fn main(_: TriviallyDecodable<bool>) { ... }值得一提的还有bool编码侧的标准库特例:bool的平凡编码(sway-lib-std/src/codec.sw)直接复用__encode_buffer_append追加原始字节;而 codec.sw 中还提供了TrivialBool { value: u64 }这样的标准库内置平凡类型。务必只在你能保证输入位模式合法的前提下使用强制平凡解码,否则会引入未定义行为级别的安全风险。
六、实践建议与总结
6.1 如何在合约开发中利用平凡编码
- 优先使用无 padding 的紧凑类型组合:全
u64/u8/b256组成的元组与结构体在编译期会被判定为平凡,编解码走零拷贝快路径,不增加二进制体积与 Gas; - 避免混入
u16/u32/str等触发逐字节编码的类型,除非确有必要; - 在 ABI 边界两侧保持相同版本的
stdlib与编译器:虽然编码体系已隔离了内部字段布局差异,但平凡性判定、编码格式仍与编译器版本绑定; - 慎用
TriviallyDecodable/TrivialBool这类强制平凡解码:它们只适合确信输入受控的场景(例如自己链下生成的、经过校验的数据)。
6.2 全文脉络回顾
- 合约调用时,调用方把方法名与实参分别编码,打包进堆上的参数区,再以
call指令连同coins / asset_id / gas交给被调用方;被调用方从调用帧第 73 / 74 字偏移读取两段字节并解码执行; - 编码的三大动机是:ABI 不稳定、返回值降级(Return Value Demotion)与别名(Aliasing)安全;
- 同一套编码机制同时服务合约调用、脚本 / 谓词入参、日志回执、configurables四大场景;
- 平凡编码 / 解码(内存布局 == 编码布局)走
aloc+mcp的零拷贝快路径,能显著节省二进制体积与 Gas;但bool、枚举等存在 trap representation 的类型即便布局相同也不能安全平凡解码。
如果希望继续深入,可以:
- 阅读讲稿原文 docs/slides/encoding.md 与续篇 docs/slides/trivial_encoding.md;
- 研读标准库实现 sway-lib-std/src/codec.sw(
Buffer/BufferReader/AbiEncode/AbiDecode/contract_call/encode/abi_decode); - 跟踪编译器侧的内建函数类型检查 sway-core/src/semantic_analysis/ast_node/expression/intrinsic_function.rs 中的
ContractCall、ContractRet、MemReprEq; - 参考 sway-lib-std/src/logging.sw 中
log的编码约束,以及 examples 目录下的counter、wallet_smart_contract、configurable_constants等示例工程,观察真实合约的 ABI 行为。
【免费下载链接】sway🌴 Empowering everyone to build reliable and efficient smart contracts.项目地址: https://gitcode.com/GitHub_Trending/sw/sway
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考