L1-L2 间信息传递

Layer 2的一个重要特征是它能与Layer 1交互。

Starknet拥有自己的 L1-L2 消息传递系统,它与其共识机制和L1上状态更新的提交方式不同。消息传递是L1上的智能合约与L2上的智能合约(或反之亦然)进行交互的一种方式,允许我们进行”跨链”交易。例如,我们可以在一个链上进行一些计算,并在另一个链上使用这个计算的结果。

在Starknet上,所有的桥接都使用 L1-L2 消息传递。假设你想要将以太坊上的代币桥接到Starknet上。你只需要将代币存入L1桥接合约,这将自动触发在L2上铸造相同代币。L1-L2 消息传递的另一个很好的用例是DeFi池化

在Starknet上,重要的是要注意消息系统是异步非对称

  • 异步性:这意味着在你的合约代码(无论是Solidity还是Cairo)中,在合约代码执行期间,你不能等待另一个链上发送消息的结果。
  • 非对称性:从以太坊发送消息到Starknet(L1->L2)是由Starknet序列器完全自动化的,这意味着消息会自动传递到L2上的目标合约。然而,当从Starknet发送消息到以太坊(L2->L1)时,Starknet序列器仅在L1上发送消息的哈希。然后,你必须通过L1上的交易来手动消耗该消息。

让我们来详细了解一下。

Starknet消息传递合约

L1-L2 消息传递系统的关键组件是StarknetCore合约。它是部署在以太坊上的一组Solidity合约,用于使Starknet正常运行。StarknetCore 的合约之一被称为StarknetMessaging,它负责在Starknet和以太坊之间传递消息。StarknetMessaging遵循一个接口,其中包含了一些函数,允许向L2发送消息,在L1上从L2接收消息以及取消消息。

interface IStarknetMessaging is IStarknetMessagingEvents {

    function sendMessageToL2(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload
    ) external returns (bytes32);

    function consumeMessageFromL2(uint256 fromAddress, uint256[] calldata payload)
        external
        returns (bytes32);

    function startL1ToL2MessageCancellation(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload,
        uint256 nonce
    ) external;

    function cancelL1ToL2Message(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload,
        uint256 nonce
    ) external;
}

Starknet消息传送合约接口

对于 L1->L2 的消息,Starknet序列器不断监听以太坊上的 StarknetMessaging 合约发出的日志。 一旦在日志中检测到消息,序列器会准备并执行 L1HandlerTransaction,以调用目标L2合约上的函数。这个过程可能需要1-2分钟(几秒钟用于挖掘以太坊区块,然后序列器必须构建并执行事务)。

L2->L1 的消息是由在L2上执行的合约准备的,并且是生成的区块的一部分。当序列器生成一个区块时,序列器将把合约执行准备的每个消息的哈希发送到L1上的 StarknetCore 合约,一旦它们所属的区块在以太坊上被证明和验证(目前大约需要3-4小时),这些消息就可以被消耗。

从以太坊向Starknet发送信息

如果你想从 Ethereum 向 Starknet 发送消息,你的 Solidity 合约必须调用 StarknetMessaging 合约的 sendMessageToL2 函数。要在 Starknet 上接收这些消息,你需要用 #[l1_handler] 属性注解可从 L1 调用的函数。

让我们看一个简单的合约,来自这个教程 我们希望向Starknet发送一条消息。 _snMessaging 是一个已经初始化为 StarknetMessaging 合约地址的状态变量。你可以在这里检查这些地址。

// Sends a message on Starknet with a single felt.
function sendMessageFelt(
    uint256 contractAddress,
    uint256 selector,
    uint256 myFelt
)
    external
    payable
{
    // We "serialize" here the felt into a payload, which is an array of uint256.
    uint256[] memory payload = new uint256[](1);
    payload[0] = myFelt;

    // msg.value must always be >= 20_000 wei.
    _snMessaging.sendMessageToL2{value: msg.value}(
        contractAddress,
        selector,
        payload
    );
}

该函数向 StarknetMessaging 合约发送一个包含单个 felt 值的消息。 请注意,如果你想发送更复杂的数据,可以这样做。只是要注意,你的 Cairo 合约只能理解 felt252 数据类型。因此,你必须确保将数据序列化为 uint256 数组时,要按照 Cairo 的序列化方案进行操作。

这里需要注意的是我们有 {value: msg.value}。实际上,我们在这里必须发送的最小值是 20,000 wei ,因为 StarknetMessaging 合约会在以太坊的存储中注册我们消息的哈希。

除了这 20,000 wei 之外,由于由序列器执行的 L1HandlerTransaction 不绑定到任何账户(消息源自 L1),你还必须确保在 L1 上支付足够的费用,以便你的消息在 L2 上被反序列化和处理。

L1HandlerTransaction 的费用计算方式与 Invoke 交易的计算方式相同。为此,你可以使用 starklysnforge 对gas消耗进行分析,估算你的消息执行成本。

sendMessageToL2的签名是:

function sendMessageToL2(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload
    ) external override returns (bytes32);

参数如下:

  • toAddress : 在 L2 上将被调用的合约地址。
  • selector : 位于 toAddress 上此合约函数的选择器。这个选择器(函数)必须具有 #[l1_handler] 属性以便被调用。
  • payload : payload 始终是一个 felt252 数组(在 Solidity 中由 uint256 表示)。因此,我们将输入 myFelt 插入到数组中。这就是为什么我们需要将输入数据插入到数组中的原因。

在Starknet方面,要接收这条信息,我们需要:

#![allow(unused)]
fn main() {
    #[l1_handler]
    fn msg_handler_felt(ref self: ContractState, from_address: felt252, my_felt: felt252) {
        assert(from_address == self.allowed_message_sender.read(), 'Invalid message sender');

        // You can now use the data, automatically deserialized from the message payload.
        assert(my_felt == 123, 'Invalid value');
    }
}

我们需要为我们的函数添加 #[l1_handler] 属性。L1处理器是一种特殊的函数,只能由L1HandlerTransaction 执行。接收来自L1的事务时不需要特别处理,因为消息会自动由顺序器中继。在你的 #[l1_handler] 函数中,重要的是要验证L1消息的发送者,以确保我们的合约只能接收来自受信任的L1合约的消息。

从Starknet向以太坊发送信息

当从Starknet发送消息到以太坊时,你将需要在Cairo合约中使用 send_message_to_l1 系统调用。该系统调用允许您向L1上的 StarknetMessaging 合约发送消息。与 L1->L2 消息不同的是,L2->L1 消息必须手动消耗,这意味着你的Solidity合约需要显式调用 StarknetMessaging 合约的 consumeMessageFromL2 函数来消耗消息。

要从 L2 向 L1 发送信息,我们在Starknet上要做的是:

#![allow(unused)]
fn main() {
        fn send_message_felt(ref self: ContractState, to_address: EthAddress, my_felt: felt252) {
            // Note here, we "serialize" my_felt, as the payload must be
            // a `Span<felt252>`.
            starknet::send_message_to_l1_syscall(to_address.into(), array![my_felt].span())
                .unwrap();
        }
}

我们只需构建payload,并将其与 L1 合约地址一起传递给系统调用函数。

在L1上,重要的部分是构建与L2上相同的payload。然后,通过传递L2合约地址和payload来调用 consumeMessageFromL2。 请注意,consumeMessageFromL2 期望的L2合约地址是在L2上发送交易的账户的合约地址,而不是执行 send_message_to_l1_syscall 的合约的地址。

function consumeMessageFelt(
    uint256 fromAddress,
    uint256[] calldata payload
)
    external
{
    let messageHash = _snMessaging.consumeMessageFromL2(fromAddress, payload);

    // You can use the message hash if you want here.

    // We expect the payload to contain only a felt252 value (which is a uint256 in solidity).
    require(payload.length == 1, "Invalid payload");

    uint256 my_felt = payload[0];

    // From here, you can safely use `my_felt` as the message has been verified by StarknetMessaging.
    require(my_felt > 0, "Invalid value");
}

正如你所看到的,在这个上下文中,我们无需验证L2中的哪个合约正在发送消息。但实际上,我们使用 consumeMessageFromL2 来验证输入(在L2上的发送者地址和payload),以确保我们只处理有效的消息。

重要的是要记住,在L1上我们发送的有效载荷是 uint256,但是在Starknet上的基本数据类型是 felt252;然而,felt252uint256 大约小4位。因此,我们必须注意我们发送的消息有效载荷中包含的值。如果在L1上构建的消息的值超过了最大的felt252,该消息将被卡住,无法在L2上被消耗。

Cairo Serde

在L1和L2之间发送消息之前,你必须记住,用Cairo编写的Starknet合约只能理解序列化数据。而序列化数据始终是一个felt252数组。 在Solidity中,我们有 uint256 类型,而 felt252uint256 大约小4位。因此,我们必须注意我们发送的消息有效载荷中包含的值。 如果在L1上构建的消息的值超过了最大的 felt252,该消息将被卡住,无法在L2上被消耗。

例如,在Cairo中,一个实际的 uint256 值表示为类似以下的结构:

#![allow(unused)]
fn main() {
struct u256 {
    low: u128,
    high: u128,
}
}

这将被序列化为两个 felts ,一个用于 low ,另一个用于 high 。这意味着要向Cairo发送一个 u256,你需要从L1发送一个包含两个值的 payload。

uint256[] memory payload = new uint256[](2);
// Let's send the value 1 as a u256 in cairo: low = 1, high = 0.
payload[0] = 1;
payload[1] = 0;

如果你想了解更多关于消息机制的信息,可以访问 Starknet 文档.

你也可以在这里找到详细的教程 来在本地测试消息传递。

Last change: 2023-11-22, commit: 93e82f9