Cairo 1 实战入门:可升级合约与跨链信息发送

本文介绍了Cairo 1中的可升级合约实现,无需代理模式,通过逻辑和状态分离实现。接着,详细阐述了StarkNet上的跨链流程,包括L2到L1和L1到L2的通信步骤,涉及合约编程、跨链测试和安全注意事项。文章还展示了如何在Cairo和Solidity中编写支持跨链的ERC20代币合约,并提供了实战代码示例。
摘要由CSDN通过智能技术生成

概述

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

如果读者阅读过笔者之前的文章就会发现,我在 solidity 中使用了 ERC20 代币 -> 可实升级合约的学习路径。为了保持文章的统一性,我准备在此文中介绍 cairo 的可升级合约编程。

此处我没有使用代理合约一词,因为 cairo 1 的可升级配置不需要代理模式。实现起来相当简单,所以为了保持文章的长度,我在代理合约基础上增加了跨链信息发送这一主题。

StarkNet 作为以太坊 L2 项目,其部署在以太坊 核心合约 提供了跨链信息传输功能,而在 StarkNet 链上,原生支持向以太坊通信的系统调用。

显然,使用这些函数可以构造一个沟通 StarkNet 和 Ethereum 的跨链应用。本文将在 Cairo 1 实战入门:编写测试部署ERC-20代币智能合约 基础上构造一个原生支持跨链的 ERC20 代币。

本文出现了大量难度不高的 solidity 代码并使用了 foundry 开发框架,如果读者不熟悉 solidity 合约编程或不熟悉 foundry 框架,请参考 Foundry教程:编写测试部署ERC-20代币智能合约

我们可以将跨链任务拆解为两部分:

  1. 部署以太坊 ERC20 代币并实现 transfer_to_L2 函数
  2. 在 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_addresspayload 的信息,其中 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_addresspayload 信息。当然,从核心合约抛出的事件中检索也可以。

当 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_addresspayload 信息,否则就无法调用 consumeMessageFromL2 以消费数据。

在真正的开发过程中,我们一般会使用 IStarknetMessaging 接口,我们会在后文展示其使用。

一个简单的流程如下:

StarkNet Message L2 -> L1

当然,此流程中没有显示技术细节。一个具有更多技术细节的流程图如下:

L22L1

在上图中,出现之前没有给出过解释的 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,
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

WongSSH

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

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

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

打赏作者

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

抵扣说明:

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

余额充值