本文详细介绍了Starknet上跨合约调用的两种方式:使用合约调度器(常规和安全)以及直接调用call_contract_syscall。通过银行合约示例展示了如何调用ERC-20代币合约的transfer和transfer_from函数,并对比了与Solidity的相似性。安全调度器允许捕获错误而不回滚整个交易,但系统级失败仍会导致回滚。文章推荐使用调度器进行类型安全的调用,仅在特殊需求时使用直接syscall。
跨合约调用是指一个合约调用另一个合约的公开函数的过程。一个常见的例子是流动性池调用 ERC-20 代币合约来转账代币进出池子。
在本文中,你将了解跨合约调用在 Starknet 上如何工作,以及如何在你的智能合约中实现它们。
在 Starknet 合约中进行跨合约调用有两种方法:
call_contract_syscall 系统调用让我们逐一了解它们。
调度器是编译器生成的结构体,用于对其他合约进行类型安全的调用。它封装了一个 ContractAddress,并实现了编译器根据你的 #[starknet::interface] 生成的 trait。
在 Solidity 中,你将目标合约的地址转换为接口类型来调用其函数。Cairo 的调度器工作方式类似,只是编译器根据你的 #[starknet::interface] 生成它,并为你处理类型转换。
当你调用另一个合约的函数时,你只需在调度器上使用参数调用它。在内部,调度器:
felt252 值call_contract_syscall 通过合约地址、函数选择器和序列化参数执行调用Span<felt252> 反序列化回预期的 Cairo 类型下图显示了当合约 A 通过调度器调用合约 B 的函数时发生的情况:

在高层面上,你进行跨合约调用的方式与调用任何常规函数相同。调度器在后台处理选择器计算、序列化和反序列化。
对于每个合约接口,编译器会生成多个调度器(查看完整列表 在此)。我们将重点关注:
Result<T, Array<felt252>>。然后你的代码可以检查结果并处理失败,而无需回滚整个交易。但是,有些情况仍然会立即导致无法捕获的回滚。我们将在本文后面讨论这些限制。我们将逐步介绍一个银行合约,用户可以在此存入和提取 RareTokens(我们前面章节中的 ERC-20 实现)。该设置包括两个合约:
RareBank:主要的银行合约。RareToken:ERC-20 代币合约。RareBank 将使用调度器来调用 RareToken 合约上的存款和取款函数。
让我们定义代币合约 IRareToken 和 IRareBank 的接口:
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..} 导入接口时,这些类型即可使用。
在我们的例子中,定义 IRareBank 和 IRareToken 会生成它们各自的调度器,如下面的动画所示:
如上所示,编译器生成了许多与调度器相关的类型,但与我们讨论最相关的(以绿色高亮显示)是:
对于 IRareBank:
IRareBankDispatcher 用于在出错时 panic 的常规合约调用IRareBankSafeDispatcher 用于返回 Result<...> 进行错误处理的调用对于 IRareToken:
IRareTokenDispatcher 用于常规调用IRareTokenSafeDispatcher 用于带错误处理的调用其他生成的类型(如 Copy、Drop、Serde 等)是 Cairo 内部用于使这些调度器正常工作的实现细节。
转到合约实现,以下是所需的导入:
#[starknet::contract]
mod RareBank {
use super::{IRareTokenDispatcher, IRareTokenDispatcherTrait};
}
我们使用 super:: 导入 IRareTokenDispatcher 结构体和 IRareTokenDispatcherTrait,因为它们是在我们定义接口的同一个模块中生成的。以下是编译器生成的 IRareTokenDispatcher 结构体(以橙色高亮显示):

调度器结构体持有目标合约的地址(将指向 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 参数会从 @TContractState 或 ref TContractState 变为纯粹的 T。
泛型类型参数 T 允许同一个 trait 被重用于不同的调度器类型。由于编译器会从你的接口生成多个调度器变体(如 IRareTokenDispatcher 和 IRareTokenSafeDispatcher),使用 T 意味着一个 trait 定义可以服务于所有这些变体。当你使用特定的调度器时,编译器会用该具体类型替换 T。
调度器的 self 是 T,即持有目标合约地址的泛型,而不是 TContractState。与合约实现不同,它无法访问合约状态。相反,它将函数调用转换为对其持有的地址的跨合约调用。
对于 trait 实现中的每个函数,编译器会生成代码,该代码:
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() 代码中发生的事情:
IRareTokenDispatcherrare_token.transfer_from(caller, this_contract, amount) 时,调度器(IRareTokenDispatcherTrait)处理翻译caller、this_contract、amount)序列化为一个 felt252 数组selector!("transfer_from") 根据函数名称计算函数选择器(一个 felt252 哈希)call_contract_syscall:
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 安全调度器类型(IRareTokenSafeDispatcher 和 IRareTokenSafeDispatcherTrait):
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) 失败)当我们捕获错误时,我们将用户的银行存款余额恢复到 user_balance(我们在扣款之前的金额),并触发 WithdrawFailed 事件,其中包含错误详情。
安全调度器仍然会回滚的情况
虽然安全合约调度器可以处理跨合约调用期间的许多错误场景,但某些系统级失败会导致整个交易立即回滚,而不是返回 Result::Err。这些包括:
Result 错误处理这些是安全调度器无法捕获的系统级失败。安全合约调度器只能捕获正常合约执行中的错误,例如失败的断言或显式回滚。
在我们的 withdraw_safe 示例中,如果 rare_token_address 指向一个不存在的合约,那么即使使用了安全调度器,当我们调用 rare_token.transfer() 时,交易也会立即回滚。类似地,如果你在调用一个工厂合约,该合约尝试使用无效的类哈希进行部署,那也会导致立即回滚。
注意: 这些限制预计将在未来的 Starknet 版本中得到解决。
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") 计算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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码