概述
读者可前往 我的博客 获得更好的阅读体验。
如果读者阅读过笔者之前的文章就会发现,我在 solidity 中使用了 ERC20 代币 -> 可实升级合约的学习路径。为了保持文章的统一性,我准备在此文中介绍 cairo 的可升级合约编程。
此处我没有使用代理合约一词,因为 cairo 1 的可升级配置不需要代理模式。实现起来相当简单,所以为了保持文章的长度,我在代理合约基础上增加了跨链信息发送这一主题。
StarkNet 作为以太坊 L2 项目,其部署在以太坊 核心合约 提供了跨链信息传输功能,而在 StarkNet 链上,原生支持向以太坊通信的系统调用。
显然,使用这些函数可以构造一个沟通 StarkNet 和 Ethereum 的跨链应用。本文将在 Cairo 1 实战入门:编写测试部署ERC-20代币智能合约 基础上构造一个原生支持跨链的 ERC20 代币。
本文出现了大量难度不高的 solidity 代码并使用了 foundry 开发框架,如果读者不熟悉 solidity 合约编程或不熟悉 foundry 框架,请参考 Foundry教程:编写测试部署ERC-20代币智能合约。
我们可以将跨链任务拆解为两部分:
- 部署以太坊 ERC20 代币并实现
transfer_to_L2
函数 - 在 HelloERC20 cairo 智能合约基础上实现
transferToL1
函数
本文所有代码都可在 helloCairoBridge 仓库内找到。
可升级合约
在 Cairo 1 实战入门:编写测试部署ERC-20代币智能合约 中,我们曾介绍过 class hash
。事实上,我们编写的 cairo 代码上链时都会被注册到 starknet 区块链的 class
状态仓库中,而合约只是 class
的运行时,用于存储运行状态。 class
和 合约的分离彻底实现了逻辑和状态的分离。
简单来看,
class
相当于逻辑合约,而合约相当于代理合约。
众所周知,可升级合约的基础就是状态和逻辑的分离。为了方便开发者进行可升级合约编程,starknet 为 cairo 语言提供了一个特殊的用于合约升级函数 replace_class_syscall(new_class_hash)
。正如函数名,此函数用于替换合约背后的 class
。这意味着,我们可以随时通过调用此函数实现运行逻辑的改变。
有很多读者可以阅读过笔者之前编写的 solidity 代理合约系列,一定对存储槽冲突问题有所耳闻。但在 starknet 中,存储槽冲突问题也消失了。
在 starknet 智能合约中,我们使用以下方法进行数据写入:
_name::write(name);
但这其实使用语法糖后的写法,其底层实现为:
fn address() -> starknet::StorageBaseAddress {
starknet::storage_base_address_const::<0x3a858959e825b7a94eb8d55c738f59c7bf4685267af5064bed5fd9c6bbc26de>()
}
fn write(value: felt252) {
// Only address_domain 0 is currently supported.
let address_domain = 0_u32;
starknet::StorageAccess::<felt252>::write(
address_domain,
address(),
value,
).unwrap_syscall()
}
其中,starknet::StorageAccess::<felt252>::write(address_domain, address, value)
是写入的核心函数,而写入地址为 变量名的 sn-keccak
哈希值 0x3a858959e825b7a94eb8d55c738f59c7bf4685267af5064bed5fd9c6bbc26de
。这与 solidity 的线性存储排布产生了鲜明对比。显然,这种依靠变量名哈希值进行存储的方式完全避免了存储槽冲突问题。我们可以进行任意的合约升级,不需要专门考虑升级前后存储变量是否会丢失或无法检索等问题。
关于此处的变量存储地址
address
的计算问题,读者可以参考 文档
综上所述,可升级合约在 cairo 1 的极易实现,只需要在合约内加入以下内容:
use starknet::syscalls::replace_class_syscall;
#[external]
fn upgrade(new_class_hash: core::starknet::class_hash::ClassHash) -> bool {
replace_class_syscall(new_class_hash);
true
}
请注意此处没有对 upgrade
进行权限控制,读者在使用时应当进行增加。
读者在参与 starknet 项目交互时,应当注意风险,可升级合约在 starknet 上可能会成为主流,这意味着项目方更容易通过可升级合约的方式跑路
跨链流程
在编写代码前,我们需要了解跨链的基本流程,该部分内容主要参考 L1-L2 messaging 文档。
L2 -> L1
从 starknet 跨链到 ethereum 主网是比较简单的。
一个简单的例子如下:
let mut message_payload: Array<felt252> = ArrayTrait::new();
message_payload.append(WITHDRAW_MESSAGE);
message_payload.append(l1_recipient.into());
message_payload.append(amount.low.into());
message_payload.append(amount.high.into());
send_message_to_l1_syscall(
to_address: read_initialized_l1_bridge(), payload: message_payload.span()
);
当我们在 cairo 合约中调用 send_message_to_l1_syscall
函数时, starknet 节点会收到 to_address
和 payload
的信息,其中 to_address
是 L1 信息接收地址,而 payload
为发送的信息内容。节点收到上述信息后,会使用以下公式计算 hash 值:
keccak256(
abi.encodePacked(
FromAddress,
ToAddress,
Payload.length,
Payload
)
);
其中各参数含义如下:
- FromAddress L2 发送方地址
- ToAddress L1 接收方地址
- Payload 发送信息
计算完成后会将上述内容使用 abi.encodePacked
进行序列化,以其作为参数请求 L1 的 核心合约 中 updateState
函数,该函数有很多作用,此处我们仅关注 L2 -> L1 跨链信息传递功能。此功能是通过 processMessages
函数实现的,其中核心部分如下:
if (isL2ToL1) {
bytes32 messageHash = keccak256(
abi.encodePacked(programOutputSlice[offset:endOffset])
);
emit LogMessageToL1(
// from=
programOutputSlice[offset + MESSAGE_TO_L1_FROM_ADDRESS_OFFSET],
// to=
address(programOutputSlice[offset + MESSAGE_TO_L1_TO_ADDRESS_OFFSET]),
// payload=
(uint256[])(programOutputSlice[offset + MESSAGE_TO_L1_PREFIX_SIZE:endOffset])
);
messages[messageHash] += 1;
}
此处的 programOutputSlice
即节点打包的请求参数,我们使用数组检索来实现请求参数的反序列化。值得注意的是,这些请求参数并没有被保存在以太坊状态中,而仅作为 LogMessageToL1
事件抛出。该事件定义为:
event LogMessageToL1(uint256 indexed fromAddress, address indexed toAddress, uint256[] payload);
故在上述代码中,只有 messages[messageHash] += 1;
进行了以太坊状态修改,所以归根到底,只有 L2 -> L1 的信息的哈希值被记录了。
如果读者对此部分感兴趣,可以阅读 源代码。
这意味着如果我们在 L2 向 L1 发送信息,我们需要自己在链下维护 from_address
和 payload
信息。当然,从核心合约抛出的事件中检索也可以。
当 L2 节点在 L1 完成数据写入后,就完成所有任务。接下来,我们需要使用 L1 合约去消费数据。我们一般会直接使用 consumeMessageFromL2
函数。为了使读者了解该函数的底层原理,我截取了其 源代码:
function consumeMessageFromL2(uint256 fromAddress, uint256[] calldata payload)
external
override
returns (bytes32)
{
bytes32 msgHash = keccak256(
abi.encodePacked(fromAddress, uint256(msg.sender), payload.length, payload)
);
require(l2ToL1Messages()[msgHash] > 0, "INVALID_MESSAGE_TO_CONSUME");
emit ConsumedMessageToL1(fromAddress, msg.sender, payload);
l2ToL1Messages()[msgHash] -= 1;
return msgHash;
}
consumeMessageFromL2
实质就是检索存储中是否存在指定跨链信息的哈希值。如果该跨链信息存在,那么返回信息哈希,否则则抛出异常。在跨链应用开发过程中,我们往往直接丢弃 consumeMessageFromL2
返回的信息哈希值。
请注意此处消费不存在的信息会直接抛出异常
正如上文所述,我们需要自己维护 from_address
和 payload
信息,否则就无法调用 consumeMessageFromL2
以消费数据。
在真正的开发过程中,我们一般会使用 IStarknetMessaging 接口,我们会在后文展示其使用。
一个简单的流程如下:
当然,此流程中没有显示技术细节。一个具有更多技术细节的流程图如下:
在上图中,出现之前没有给出过解释的 StarknetOS
名词。简单来说,我们可以认为其指 cairo 运行环境。但事实上,该词有更加深层的隐喻。我们所学习的 cairo 1
都是无状态的,无法进行数据存储等操作,而缺少数据存储等操作显然无法构造真正的应用。所以 StarkNet
开发者为 Cairo 提供了一系列函数用于拓展 Cairo 编程语言。当 cairo 调用到这些函数后,类似操作系统中的应用进行系统调用,控制权会被转移到 cairo 外的程序,这一过程也类似操作系统中的用户态到内核态的转移。基于这种相似性,我们称 starknet 项目组构造的 cairo 运行时环境为 StarknetOS
。
这样解释了在测试过程中,我们为什么使用
cairo-test --starknet .
。此处的--starknet
就意味着测试过程中应为运行时增加StarknetOS
提供的系统调用
所有 StarknetOS
提供的系统调用都可以在 corelib/src/starknet/syscalls.cairo 中找到。如果读者访问此文件,会发现文件中的函数都没有定义,这是因为这些函数的逻辑代码都是在 cairo 运行环境中实现的。
最后,我们讨论费用问题。不难发现,L2 -> L1 的信息传递最大的花销在于 L1 中的核心合约将消息哈希值写入以太坊状态。这一部分会消耗 20k gas ,当然事件的抛出也会消耗一定 gas。starknet 在其 gas 计算方法上应该已经兼顾这一部分,读者不太用担心交易失败的问题。
L1 -> L2
相比于 L2 -> L1 的信息传递需要 ciaro 发送和 solidity 接受,L1 -> L2 的跨链调用则更加简单,仅需要 L1 合约发起请求即可。但另一方面,L1 -> L2 的交易费用是重点,一旦计算操作很有可能出现 ETH 丢失的现象。
此处我们使用了 跨链调用 一词,L1 -> L2 的信息发送一定会触发 L2 合约函数,我们会在后文分析其具体原理。
跨链调用的第一步是 L1 合约调用 starknet 核心合约的 sendMessageToL2
函数,该函数 源代码 如下:
function sendMessageToL2(
uint256 toAddress,