Starknet上的跨合约调用

本文详细介绍了Starknet上跨合约调用的两种方式:使用合约调度器(常规和安全)以及直接调用call_contract_syscall。通过银行合约示例展示了如何调用ERC-20代币合约的transfer和transfer_from函数,并对比了与Solidity的相似性。安全调度器允许捕获错误而不回滚整个交易,但系统级失败仍会导致回滚。文章推荐使用调度器进行类型安全的调用,仅在特殊需求时使用直接syscall。

Starknet 上的跨合约调用

跨合约调用是指一个合约调用另一个合约的公开函数的过程。一个常见的例子是流动性池调用 ERC-20 代币合约来转账代币进出池子。

在本文中,你将了解跨合约调用在 Starknet 上如何工作,以及如何在你的智能合约中实现它们。

进行跨合约调用的方法

在 Starknet 合约中进行跨合约调用有两种方法:

  1. 使用合约调度器(Dispatcher)
  2. 直接使用 call_contract_syscall 系统调用

让我们逐一了解它们。

1. 使用合约调度器

调度器是编译器生成的结构体,用于对其他合约进行类型安全的调用。它封装了一个 ContractAddress,并实现了编译器根据你的 #[starknet::interface] 生成的 trait。

在 Solidity 中,你将目标合约的地址转换为接口类型来调用其函数。Cairo 的调度器工作方式类似,只是编译器根据你的 #[starknet::interface] 生成它,并为你处理类型转换。

当你调用另一个合约的函数时,你只需在调度器上使用参数调用它。在内部,调度器:

  • 在编译时根据函数名称计算函数选择器
  • 将函数参数序列化为 felt252
  • 使用 call_contract_syscall 通过合约地址、函数选择器和序列化参数执行调用
  • 并将返回的 Span<felt252> 反序列化回预期的 Cairo 类型

下图显示了当合约 A 通过调度器调用合约 B 的函数时发生的情况:

调度器 trait 实现,显示合约 A 通过四个步骤调用合约 B:选择器计算、序列化、系统调用执行和反序列化

在高层面上,你进行跨合约调用的方式与调用任何常规函数相同。调度器在后台处理选择器计算、序列化和反序列化。

对于每个合约接口,编译器会生成多个调度器(查看完整列表 在此)。我们将重点关注:

  • 常规合约调度器:进行跨合约调用,失败时 panic
  • 安全合约调度器:进行跨合约调用,返回 Result<T, Array<felt252>>。然后你的代码可以检查结果并处理失败,而无需回滚整个交易。但是,有些情况仍然会立即导致无法捕获的回滚。我们将在本文后面讨论这些限制。

构建一个简单的银行合约来演示跨合约调用

我们将逐步介绍一个银行合约,用户可以在此存入和提取 RareTokens(我们前面章节中的 ERC-20 实现)。该设置包括两个合约:

  • RareBank:主要的银行合约。
  • RareToken:ERC-20 代币合约。

RareBank 将使用调度器来调用 RareToken 合约上的存款和取款函数。

让我们定义代币合约 IRareTokenIRareBank 的接口:

use starknet::ContractAddress;

// RareToken ERC20 接口
#[starknet::interface]
pub trait IRareToken<TContractState> {
    fn total_supply(self: @TContractState) -> u256;
    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
    fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // 用于测试
}

// RareBank 接口
#[starknet::interface]
pub trait IRareBank<TContractState> {
    fn deposit(ref self: TContractState, amount: u256);
    fn withdraw(ref self: TContractState, amount: u256);
    fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
}

当你使用 #[starknet::interface] 定义接口时,编译器会自动为其生成调度器类型。当你使用 use super::{I..} 导入接口时,这些类型即可使用。

在我们的例子中,定义 IRareBankIRareToken 会生成它们各自的调度器,如下面的动画所示:

如上所示,编译器生成了许多与调度器相关的类型,但与我们讨论最相关的(以绿色高亮显示)是:

对于 IRareBank

  • IRareBankDispatcher 用于在出错时 panic 的常规合约调用
  • IRareBankSafeDispatcher 用于返回 Result<...> 进行错误处理的调用

对于 IRareToken

  • IRareTokenDispatcher 用于常规调用
  • IRareTokenSafeDispatcher 用于带错误处理的调用

其他生成的类型(如 CopyDropSerde 等)是 Cairo 内部用于使这些调度器正常工作的实现细节。

在银行合约中使用常规合约调度器

转到合约实现,以下是所需的导入:

#[starknet::contract]
mod RareBank {
    use super::{IRareTokenDispatcher, IRareTokenDispatcherTrait};
}

我们使用 super:: 导入 IRareTokenDispatcher 结构体和 IRareTokenDispatcherTrait,因为它们是在我们定义接口的同一个模块中生成的。以下是编译器生成的 IRareTokenDispatcher 结构体(以橙色高亮显示):

编译器生成的 IRareTokenDispatcher 结构体,显示 contract_address 字段的类型为 ContractAddress

调度器结构体持有目标合约的地址(将指向 RareToken 合约),编译器生成相应的 IRareTokenDispatcherTrait,其中包含我们可以从 IRareToken 接口调用的所有函数签名:

trait IRareTokenDispatcherTrait<T> {
    fn total_supply(self: T) -> u256;
    fn balance_of(self: T, account: ContractAddress) -> u256;
    fn allowance(self: T, owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(self: T, recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(self: T, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
    fn approve(self: T, spender: ContractAddress, amount: u256) -> bool;
    fn name(self: T) -> ByteArray;
    fn symbol(self: T) -> ByteArray;
    fn decimals(self: T) -> u8;
    fn mint(self: T, recipient: ContractAddress, amount: u256) -> bool;
}

// 编译器也会生成这个实现
impl IRareTokenDispatcherImpl of IRareTokenDispatcherTrait<IRareTokenDispatcher> {
    fn transfer(self: IRareTokenDispatcher, recipient: ContractAddress, amount: u256) -> bool {
        //逻辑在此
    }
    // ... 其他函数实现
}

请注意,所有函数签名都与我们在 IRareToken 接口中定义的完全匹配,但是 self 参数会从 @TContractStateref TContractState 变为纯粹的 T

泛型类型参数 T 允许同一个 trait 被重用于不同的调度器类型。由于编译器会从你的接口生成多个调度器变体(如 IRareTokenDispatcherIRareTokenSafeDispatcher),使用 T 意味着一个 trait 定义可以服务于所有这些变体。当你使用特定的调度器时,编译器会用该具体类型替换 T

调度器的 selfT,即持有目标合约地址的泛型,而不是 TContractState。与合约实现不同,它无法访问合约状态。相反,它将函数调用转换为对其持有的地址的跨合约调用。

对于 trait 实现中的每个函数,编译器会生成代码,该代码:

  • 将函数参数序列化为 calldata(一个 felt252 值数组)
  • 使用 call_contract_syscall 进行底层合约调用,传入合约地址、函数选择器和序列化的 calldata
  • 将返回的值反序列化为预期的返回类型

这就是我们之前提到的“翻译”过程:调度器将高级 Cairo 函数调用转换为底层系统调用,执行它们,并将结果转换回 Cairo 类型。

以下是完整的银行合约实现。构造函数使用所有者和 RareToken 合约地址设置银行。deposit() 接受用户的代币存款,withdraw() 将代币转回用户,get_balance() 返回用户当前的银行存款余额:

use starknet::ContractAddress;

// RareToken ERC20 接口 - 定义我们可以在代币合约上调用的函数
#[starknet::interface]
pub trait IRareToken<TContractState> {
    fn total_supply(self: @TContractState) -> u256;
    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
    fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // 用于测试
}

// RareBank 接口 - 定义银行函数
#[starknet::interface]
pub trait IRareBank<TContractState> {
    fn deposit(ref self: TContractState, amount: u256);
    fn withdraw(ref self: TContractState, amount: u256);
    fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
}

// RareBank 合约 - 管理 RareToken 的存款和取款
#[starknet::contract]
mod RareBank {
    use starknet::{ContractAddress, get_caller_address, get_contract_address};
    use starknet::storage::{
        StoragePointerReadAccess, StoragePointerWriteAccess,
        Map, StoragePathEntry
    };

    // 导入用于跨合约调用的生成的调度器和 trait
    use super::{IRareTokenDispatcher, IRareTokenDispatcherTrait};

    #[storage]
    struct Storage {
        owner: ContractAddress,
        rare_token: ContractAddress,  // 我们将交互的 RareToken 合约的地址
        balances: Map<ContractAddress, u256>, // 将用户地址映射到他们的银行存款余额
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    pub enum Event {
        DepositSuccessful: DepositSuccessful,
        WithdrawSuccessful: WithdrawSuccessful,
    }

    #[derive(Drop, starknet::Event)]
    struct DepositSuccessful {
        user: ContractAddress,
        amount: u256
    }

    #[derive(Drop, starknet::Event)]
    struct WithdrawSuccessful {
        user: ContractAddress,
        amount: u256
    }

    // 构造函数使用所有者和 RareToken 合约地址设置银行
    #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress, rare_token_address: ContractAddress) {
        assert!(owner !=  0.try_into().unwrap(), "address zero detected");
        assert!(rare_token_address !=  0.try_into().unwrap(), "address zero detected");
        self.owner.write(owner);
        self.rare_token.write(rare_token_address);  // 存储代币合约地址
    }

    // 实现 IRareBank 接口并使函数外部可调用
    #[abi(embed_v0)]
    impl RareBankImpl of super::IRareBank<ContractState> {
        fn deposit(ref self: ContractState, amount: u256) {
            assert!(amount > 0, "can't deposit zero amount");

            let caller = get_caller_address();
            let this_contract = get_contract_address();
            let rare_token_address = self.rare_token.read();  // 获取存储的代币地址

            // 创建指向 RareToken 合约的调度器实例
            let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };

            // 跨合约调用:将代币从调用者转移到该银行合约
            // 这会调用 RareToken 合约上的 transfer_from 函数
            // 注意:调用者必须已批准该合约花费至少 `amount` 代币
            let success = rare_token.transfer_from(caller, this_contract, amount);
            assert!(success, "transfer failed");

            // 更新我们银行存储中调用者的余额
            let prev_balance = self.balances.entry(caller).read();
            self.balances.entry(caller).write(prev_balance + amount);

            // 触发 DepositSuccessful 事件
            self.emit(DepositSuccessful { user: caller, amount });
        }

        fn withdraw(ref self: ContractState, amount: u256) {
            let caller = get_caller_address();
            let rare_token_address = self.rare_token.read();

            assert!(rare_token_address !=  0.try_into().unwrap(), "RareToken not set");

            // 检查调用者是否在银行中有足够的余额
            let user_balance = self.balances.entry(caller).read();
            assert!(user_balance >= amount, "insufficient funds");

            // 先更新余额
            self.balances.entry(caller).write(user_balance - amount);

            // 创建指向 RareToken 合约的调度器实例
            let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };

            // 跨合约调用:将代币从银行转回给调用者
            // 这会调用 RareToken 合约上的 transfer 函数
            let success = rare_token.transfer(caller, amount);
            assert!(success, "transfer failed");

            // 触发 WithdrawSuccessful 事件
            self.emit(WithdrawSuccessful { user: caller, amount });
        }

        // 查看函数,用于检查用户在银行中的余额
        fn get_balance(self: @ContractState, user: ContractAddress) -> u256 {
            self.balances.entry(user).read()
        }
    }
}

看下面 deposit() 函数中的这几行,这里展示了 RareBank 如何使用调度器调用 RareToken 合约上的 transfer_from 来将代币从调用者转移到银行:

// 创建一个指向 *RareToken* 合约的调度器实例
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };

// 使用调度器调用 *RareToken* 合约上的 transfer_from
let success = rare_token.transfer_from(caller, this_contract, amount);

Solidity 中的等价代码为:

// 将地址转换为接口类型
// transferFrom 成功时返回 true,失败时回滚
IERC20 rareToken = IERC20(rareTokenAddress);
bool success = rareToken.transferFrom(msg.sender, address(this), amount);

Cairo 和 Solidity 都使用相同的方法:将合约地址包裹在接口定义中,以对外部合约进行类型安全的调用。语法略有不同(Solidity 使用 IERC20(address) 包裹,而 Cairo 使用结构体初始化),但底层概念是相同的。

调度器在 deposit() 函数中的工作方式

以下是 Cairo deposit() 代码中发生的事情:

  • 创建调度器实例: 我们使用 RareToken 合约的地址实例化 IRareTokenDispatcher
  • 调用函数:当我们调用 rare_token.transfer_from(caller, this_contract, amount) 时,调度器(IRareTokenDispatcherTrait)处理翻译
  • 翻译过程
    • 调度器将参数(callerthis_contractamount)序列化为一个 felt252 数组
    • 使用 selector!("transfer_from") 根据函数名称计算函数选择器(一个 felt252 哈希)
    • 使用以下参数调用 call_contract_syscall
      • RareToken 合约地址
      • 计算出的函数选择器
      • 序列化参数
    • 将返回的 felt252 数组反序列化回 bool 结果

一旦跨合约调用成功,RareToken 合约的状态就会被更新(代币从调用者转移到银行),然后银行合约继续执行其自身的逻辑:

// 更新用户在我们银行存储中的余额
let prev_balance = self.balances.entry(caller).read();
self.balances.entry(caller).write(prev_balance + amount);

// 触发 DepositSuccessful 事件
self.emit(DepositSuccessful { user: caller, amount });

相同的模式适用于 withdraw() 函数:

let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
let success = rare_token.transfer(caller, amount);

使用安全合约调度器处理错误

与常规合约调度器不同,当你使用安全调度器调用函数时,你不会直接得到结果。相反,你会得到一个 Result 类型,它可以是:

  • Ok(value):调用成功,返回一个值
  • Err(error_data):调用失败,返回错误信息

这让你可以自行处理错误。你使用 match 来处理两种情况,并决定在发生错误时的应对方式。

扩展 RareBank 以使用安全调度器

让我们创建 withdraw_safe() 函数,这个版本使用安全调度器来处理来自 RareToken 合约的错误。当我们调用 rare_token.transfer() 且它失败(返回错误)时,我们不会 panic 并回滚整个交易,而是会恢复用户的银行存款余额并触发一个错误事件。

在现有的调度器导入旁边导入 RareToken 安全调度器类型(IRareTokenSafeDispatcherIRareTokenSafeDispatcherTrait):

use super::{
    IRareTokenDispatcher, IRareTokenDispatcherTrait,
    IRareTokenSafeDispatcher, IRareTokenSafeDispatcherTrait, //新添加的
};

接下来,定义一个用于捕获取款失败的新事件(WithdrawFailed):

#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
    DepositSuccessful: DepositSuccessful,
    WithdrawSuccessful: WithdrawSuccessful,
    WithdrawFailed: WithdrawFailed,  // 新事件
}

#[derive(Drop, starknet::Event)]
struct WithdrawFailed {
    user: ContractAddress,
    amount: u256,
    error: Array<felt252>,
}

更新 IRareBank 接口以包含新函数:

#[starknet::interface]
pub trait IRareBank<TContractState> {
    fn deposit(ref self: TContractState, amount: u256);
    fn withdraw(ref self: TContractState, amount: u256);
    fn withdraw_safe(ref self: TContractState, amount: u256);  // 添加这个
    fn get_balance(self: @TContractState, user: ContractAddress) -> u256;
}

现在实现 withdraw_safe 函数。在使用安全调度器时,需要 #[feature("safe_dispatcher")] 属性。没有它,你会收到关于使用不稳定功能的编译器警告。该属性显式为此函数启用了安全调度器用法:

#[feature("safe_dispatcher")]
fn withdraw_safe(ref self: ContractState, amount: u256) {
    let caller = get_caller_address();
    let rare_token_address = self.rare_token.read();

    // 检查调用者是否在银行中有足够的余额
    let user_balance = self.balances.entry(caller).read();
    assert!(user_balance >= amount, "insufficient funds");

    // 先更新余额
    self.balances.entry(caller).write(user_balance - amount);

    // 创建安全调度器实例
    let rare_token = IRareTokenSafeDispatcher { contract_address: rare_token_address };

    match rare_token.transfer(caller, amount) {
        Result::Ok(_) => {
            // 转账成功 - RareToken 在成功时总是返回 true
            self.emit(WithdrawSuccessful { user: caller, amount });
        },
        Result::Err(error) => {
            // 转账 panic - 恢复余额并触发错误
            self.balances.entry(caller).write(user_balance);
            self.emit(WithdrawFailed { user: caller, amount, error });
        },
    }
}

withdraw_safe 函数首先检查用户余额,然后扣除取款金额。我们创建了一个指向 RareToken 合约的安全调度器,并调用 transfer()

match 语句处理返回的 Result

  • Result::Ok(_):转账函数执行成功。RareToken 合约的 transfer 函数在成功时总是返回 true——所有错误情况都会导致函数 panic。
  • Result::Err(error):转账函数 panic。这可能发生在:
    • 发送者余额不足(assert(sender_prev_balance >= amount) 失败)
    • 交易验证失败
    • transfer 函数中任何其他断言失败

当我们捕获错误时,我们将用户的银行存款余额恢复到 user_balance(我们在扣款之前的金额),并触发 WithdrawFailed 事件,其中包含错误详情。

安全调度器仍然会回滚的情况

虽然安全合约调度器可以处理跨合约调用期间的许多错误场景,但某些系统级失败会导致整个交易立即回滚,而不是返回 Result::Err。这些包括:

  • 调用指定地址不存在的合约
  • Cairo Zero 合约抛出的错误,这些合约不支持 Result 错误处理
  • 当被调用的合约内部尝试使用无效参数部署合约时
  • 当被调用的合约内部尝试使用不存在的类哈希进行升级时

这些是安全调度器无法捕获的系统级失败。安全合约调度器只能捕获正常合约执行中的错误,例如失败的断言或显式回滚。

在我们的 withdraw_safe 示例中,如果 rare_token_address 指向一个不存在的合约,那么即使使用了安全调度器,当我们调用 rare_token.transfer() 时,交易也会立即回滚。类似地,如果你在调用一个工厂合约,该合约尝试使用无效的类哈希进行部署,那也会导致立即回滚。

注意: 这些限制预计将在未来的 Starknet 版本中得到解决。

2. 直接使用 call_contract_syscall 系统调用

Cairo 使用 call_contract_syscall 来进行跨合约调用。虽然合约调度器在内部使用这个系统调用,但当我们需要手动控制序列化和反序列化时,可以直接调用它。

要使用 call_contract_syscall,我们需要导入以下内容:

use starknet::{SyscallResultTrait, syscalls};

SyscallResultTrait 提供了 .unwrap_syscall() 方法来从系统调用操作中提取结果,syscalls 模块包含我们将用于进行底层调用的 call_contract_syscall 函数。

call_contract_syscall 实现示例

下面的代码展示了我们的 deposit 函数如何直接使用 call_contract_syscall 来执行跨合约调用:

fn deposit_with_direct_syscall(ref self: ContractState, amount: u256) {
    assert!(amount > 0, "can't deposit zero amount");

    let caller = get_caller_address();
    let this_contract = get_contract_address();
    let rare_token_address = self.rare_token.read();

    // 手动将函数参数序列化为 felt252 数组
    let mut call_data: Array<felt252> = array![];
    Serde::serialize(@caller, ref call_data);        // 发送者
    Serde::serialize(@this_contract, ref call_data); // 接收者
    Serde::serialize(@amount, ref call_data);        // 金额

    // === 进行直接系统调用 === //
    let mut res = syscalls::call_contract_syscall(
        rare_token_address,
        selector!("transfer_from"),
        call_data.span(),
    ).unwrap_syscall();

    // 手动反序列化响应
    let success: bool = Serde::<bool>::deserialize(ref res).unwrap();
    assert!(success, "transfer failed");

    // 更新余额并触发事件
    let prev_balance = self.balances.entry(caller).read();
    self.balances.entry(caller).write(prev_balance + amount);

    self.emit(DepositSuccessful { user: caller, amount });
}

call_contract_syscall 需要三个参数:

let mut res = syscalls::call_contract_syscall(
        rare_token_address,
        selector!("transfer_from"),
        call_data.span(),
    ).unwrap_syscall();
  • 合约地址(rare_token_address):要调用的目标合约
  • 函数选择器:使用 selector!("function_name") 计算
  • Calldata:作为 Span<felt252> 序列化的函数参数

我们必须使用 Serde::serialize() 手动处理参数的序列化,并使用 Serde::deserialize() 手动处理响应的反序列化。

deposit_with_direct_syscall() 做的事情与常规合约调度器中的 deposit() 完全相同。

推荐的跨合约调用方法

不建议对标准合约交互直接使用 call_contract_syscall,因为它:

  • 需要手动序列化/反序列化
  • 缺乏编译时类型检查
  • 比调度器更容易出现序列化错误

相反,使用合约调度器方法:

let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
let success = rare_token.transfer_from(caller, this_contract, amount);

由于合约调度器自动处理序列化和类型检查,只有在调度器无法满足你的特定要求时才应使用直接系统调用。

总结

我们已经介绍了在 Starknet 上进行跨合约调用的主要方法:合约调度器(常规和安全)以及直接系统调用。

对于大多数用例,合约调度器是推荐的方法。当调用必须成功时,使用常规调度器。当需要处理失败而不回滚整个交易时,使用安全调度器,但要记住,正如我们讨论的,某些系统级失败仍然可能导致立即回滚。直接系统调用适用于需要显式序列化控制的特殊需求。

在使用跨合约调用构建时,始终验证输入,谨慎处理错误,并注意回滚或恶意合约,以保持你的合约安全。

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/