跨合约调用是指一个合约调用另一个合约的公共函数。一个常见的例子是流动性池调用 ERC-20 代币合约将代币转入或转出流动性池。
在本文中,你将了解 Starknet 上跨合约调用的工作原理,以及如何在智能合约中实现它们。
实现跨合约调用的方法
在 Starknet 合约中,有两种实现跨合约调用的方法:
- 使用合约调度器 (dispatcher)
- 直接使用
call_contract_syscall系统调用 (syscall)
让我们逐一了解。
1. 使用合约调度器
调度器是由编译器生成的结构体,允许对其他合约进行类型安全的调用。它包装了一个 ContractAddress,并实现了编译器从你的 #[starknet::interface] 生成的 trait。
在 Solidity 中,你将目标合约的地址强制转换为接口类型以调用其函数。Cairo 的调度器工作原理类似,不同之处在于编译器会根据你的 #[starknet::interface] 生成它,并为你处理转换。
当你调用另一个合约的函数时,只需使用参数在调度器上调用它即可。在内部,调度器会:
- 在编译时根据函数名计算函数选择器 (function selector)
- 将函数参数序列化为
felt252值 - 使用
call_contract_syscall执行调用,传入合约地址、函数选择器和序列化后的参数 - 并将返回的
Span<felt252>反序列化回预期的 Cairo 类型
下图展示了当合约 A 通过调度器调用合约 B 的函数时发生的过程:

从高层次来看,进行跨合约调用的方式与调用任何常规函数的方法相同。调度器在幕后处理选择器计算、序列化和反序列化。
对于每个合约接口,编译器会生成几个调度器(完整列表见此处)。我们将重点关注:
- 常规合约调度器 (Regular contract dispatcher):进行跨合约调用,并在失败时引发 panic
- 安全合约调度器 (Safe contract dispatcher):进行跨合约调用并返回
Result<T, Array<felt252>>。你的代码随后可以检查结果并处理失败,而不会回滚整个交易。然而,在某些情况下仍然会导致无法捕获的立即回滚。我们将在本文稍后讨论这些限制。
构建一个简单的银行合约来演示跨合约调用
我们将通过一个银行合约进行演示,用户可以在其中存款和提取 RareTokens(我们在前一章中的 ERC-20 实现)。该设置包含两个合约:
RareBank:主要的银行合约。RareToken:ERC-20 代币合约。
RareBank 将使用调度器调用 RareToken 合约上的函数来进行存款和提款。
让我们定义代币合约接口 IRareToken 和 IRareBank:
use starknet::ContractAddress;
// RareToken ERC20 Interface
#[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; // For testing
}
// RareBank Interface
#[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;
}
// The compiler also generates this implementation
impl IRareTokenDispatcherImpl of IRareTokenDispatcherTrait<IRareTokenDispatcher> {
fn transfer(self: IRareTokenDispatcher, recipient: ContractAddress, amount: u256) -> bool {
//logic goes in
}
// ... other function implementations
}
请注意,所有的函数签名与我们在 IRareToken 接口中定义的完全匹配,但 self 参数从 @TContractState 或 ref TContractState 变成了仅为 T。
泛型参数 T 允许同一个 trait 被不同的调度器类型复用。由于编译器会从你的接口生成多种调度器变体(例如 IRareTokenDispatcher 和 IRareTokenSafeDispatcher),使用 T 意味着一个 trait 定义可以服务于所有这些变体。当你使用特定的调度器时,编译器会将 T 替换为该具体类型。
调度器的 self 是 T,这是一个保存目标合约地址而不是 TContractState 的泛型。与合约实现不同,它无法访问合约状态。相反,它将函数调用转换为向其保存的地址发起的跨合约调用。
对于 trait 实现中的每个函数,编译器生成的代码会:
- 将函数参数序列化为 calldata(一个
felt252值数组) - 使用
call_contract_syscall发起底层合约调用,传入合约地址、函数选择器和序列化后的 calldata - 将返回值反序列化为预期的返回类型
这就是我们前面提到的“转换”过程:调度器将高层的 Cairo 函数调用转换为底层的 syscall,执行它们,并将结果转换回 Cairo 类型。
以下是完整的银行合约实现。构造函数使用所有者和 RareToken 合约地址来设置银行。deposit() 接受用户的代币存款,withdraw() 将代币转回给用户,get_balance() 返回用户当前的银行余额:
use starknet::ContractAddress;
// RareToken ERC20 Interface - defines functions we can call on the token contract
#[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; // For testing
}
// RareBank Interface - defines the bank's functions
#[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 Contract - manages RareToken deposits and withdrawals
#[starknet::contract]
mod RareBank {
use starknet::{ContractAddress, get_caller_address, get_contract_address};
use starknet::storage::{
StoragePointerReadAccess, StoragePointerWriteAccess,
Map, StoragePathEntry
};
// import the generated dispatcher and trait for cross contract calls
use super::{IRareTokenDispatcher, IRareTokenDispatcherTrait};
#[storage]
struct Storage {
owner: ContractAddress,
rare_token: ContractAddress, // address of the RareToken contract we'll interact with
balances: Map<ContractAddress, u256>, // maps user addresses to their bank balances
}
#[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
}
// constructor sets up the bank with owner and RareToken contract address
#[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); // store the token contract address
}
// Implements the IRareBank interface and makes functions externally callable
#[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(); // get the stored token address
// create dispatcher instance pointing to the RareToken contract
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
// cross contract call: transfer tokens from caller to this bank contract
// this calls the transfer_from function on the RareToken contract
// note: caller must have approved this contract to spend at least `amount` tokens
let success = rare_token.transfer_from(caller, this_contract, amount);
assert!(success, "transfer failed");
// update the caller's balance in our bank's storage
let prev_balance = self.balances.entry(caller).read();
self.balances.entry(caller).write(prev_balance + amount);
// emit DepositSuccessful event
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");
// check if caller has sufficient balance in the bank
let user_balance = self.balances.entry(caller).read();
assert!(user_balance >= amount, "insufficient funds");
// update balance first
self.balances.entry(caller).write(user_balance - amount);
// create dispatcher instance pointing to the RareToken contract
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
// cross contract call: transfer tokens from bank back to caller
// this calls the transfer function on the RareToken contract
let success = rare_token.transfer(caller, amount);
assert!(success, "transfer failed");
// emit WithdrawSuccessful event
self.emit(WithdrawSuccessful { user: caller, amount });
}
// view function to check user's balance in the bank
fn get_balance(self: @ContractState, user: ContractAddress) -> u256 {
self.balances.entry(user).read()
}
}
}
查看下面 deposit() 函数中的代码行,这里展示了 RareBank 如何使用调度器调用 RareToken 合约上的 transfer_from,以将代币从调用者转移到银行:
// create a dispatcher instance pointing to the *RareToken* contract
let rare_token = IRareTokenDispatcher { contract_address: rare_token_address };
// use the dispatcher to call transfer_from on the *RareToken* contract
let success = rare_token.transfer_from(caller, this_contract, amount);
在 Solidity 中的等效代码如下:
// cast the address to the interface type
// transferFrom returns true on success, reverts on failure
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) 处理转换 - 转换过程:
- 调度器将参数(
caller、this_contract、amount)序列化为felt252数组 - 使用
selector!("transfer_from")从函数名计算函数选择器(一个felt252哈希) - 调用
call_contract_syscall,传入:- RareToken 合约地址
- 计算出的函数选择器
- 序列化后的参数
- 将返回的
felt252数组反序列化回bool结果
- 调度器将参数(
一旦跨合约调用成功,RareToken 合约的状态就会更新(代币从调用者转移到银行),然后银行合约继续执行自己的逻辑:
// update the user's balance in the bank's storage
let prev_balance = self.balances.entry(caller).read();
self.balances.entry(caller).write(prev_balance + amount);
// emit DepositSuccessful event
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, //NEWLY ADDED
};
接下来,定义一个新事件来捕获提款失败(WithdrawFailed):
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
DepositSuccessful: DepositSuccessful,
WithdrawSuccessful: WithdrawSuccessful,
WithdrawFailed: WithdrawFailed, // New event
}
#[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); // ADD THIS
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();
// check if caller has sufficient balance in the bank
let user_balance = self.balances.entry(caller).read();
assert!(user_balance >= amount, "insufficient funds");
// update balance first
self.balances.entry(caller).write(user_balance - amount);
// CREATE SAFE DISPATCHER INSTANCE
let rare_token = IRareTokenSafeDispatcher { contract_address: rare_token_address };
match rare_token.transfer(caller, amount) {
Result::Ok(_) => {
// Transfer succeeded - RareToken always returns true on success
self.emit(WithdrawSuccessful { user: caller, amount });
},
Result::Err(error) => {
// Transfer panicked - restore balance and emit error
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。这些情况包括:
- 调用指定地址处不存在的合约
- Cairo Zero 合约抛出的错误,它们不支持
Result错误处理 - 被调用的合约在内部尝试使用无效参数部署合约
- 被调用的合约在内部尝试使用不存在的 class hash 进行升级
这些是安全调度器无法捕获的系统级失败。安全合约调度器仅能捕获正常合约执行中的错误,如失败的断言或显式回滚。
在我们的 withdraw_safe 示例中,如果 rare_token_address 指向一个不存在的合约,当我们调用 rare_token.transfer() 时,交易会立即回滚,即使使用了安全调度器。同样,如果你调用一个尝试使用无效 class hash 进行部署的工厂合约,那也会导致立即回滚。
注意:这些限制预计将在未来的 Starknet 版本中得到解决。
2. 直接使用 call_contract_syscall 系统调用
Cairo 使用 call_contract_syscall 来进行跨合约调用。虽然合约调度器在内部使用了此 syscall,但当我们需要手动控制序列化和反序列化时,也可以直接调用它。
要使用 call_contract_syscall,我们需要导入以下内容:
use starknet::{SyscallResultTrait, syscalls};
SyscallResultTrait 提供了 .unwrap_syscall() 方法用于从 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();
// manually serialize function arguments into felt252 array
let mut call_data: Array<felt252> = array![];
Serde::serialize(@caller, ref call_data); // sender
Serde::serialize(@this_contract, ref call_data); // recipient
Serde::serialize(@amount, ref call_data); // amount
// === MAKE THE DIRECT SYSCALL === //
let mut res = syscalls::call_contract_syscall(
rare_token_address,
selector!("transfer_from"),
call_data.span(),
).unwrap_syscall();
// manually deserialize the response
let success: bool = Serde::<bool>::deserialize(ref res).unwrap();
assert!(success, "transfer failed");
// update balance and emit event
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 上进行跨合约调用的主要方法:合约调度器(常规和安全)以及直接系统调用。
对于大多数用例,合约调度器是推荐的方法。当调用必须成功时使用常规调度器。当需要处理失败且不回滚整个交易时使用安全调度器,但请记住,正如我们所讨论的,某些系统级失败仍可能导致立即回滚。直接的系统调用可满足需要显式序列化控制的特殊需求。
在使用跨合约调用进行构建时,始终验证输入、仔细处理错误,并注意回滚或恶意合约,以确保合约的安全。