原生多重调用(Native multicall)是 Starknet 将多个合约调用捆绑成单个原子交易的能力。某些去中心化应用的工作流程需要按顺序签署多个交易。代币兑换就是一个常见的例子。当你想要在传统的 DEX 上将代币 A 兑换为代币 B 时,你必须签署多个交易:
- 你需要签名以授权(approve)DEX 合约使用你的代币 A
- 然后再次签名以将代币 A 兑换为代币 B
这是一个两步交易过程,每次签名都需要支付 gas 费。更重要的是,这两个交易没有原子性保证。如果授权成功但兑换失败,DEX 合约将无限期地保留对你代币的使用权限。这种悬挂的授权(dangling approval)会产生一个攻击面:如果 DEX 合约后来遭到漏洞利用,攻击者可以利用合约现有的 transferFrom 权限,在无需你进行任何操作的情况下抽干已授权的金额。
Starknet 通过允许将多个合约调用捆绑成单个原子交易消除了这些问题。你只需签名一次,并支付一次 gas 费。
这种能力通过其原生账户抽象架构内置于 Starknet 协议中。在本文中,我们将探讨 multicall 的工作原理,并使用 starknet.js 将代币授权和存款作为单个原子交易执行来进行演示。
multicall 在底层是如何工作的
正如上一章所述,Starknet 实现了原生账户抽象,其中每个账户都是一个智能合约。每个账户合约都有一个 __execute__ 函数,当发送交易时,协议会调用该函数。它接收一个调用数组来执行:
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>>
calls: Array<Call> 参数正是实现 multicall 的关键。它允许该函数在单笔交易中执行一个或多个操作。
该数组中的每个 Call 定义如下:
#[derive(Drop, Serde, Debug)]
struct Call {
to: ContractAddress, // Target contract address
selector: felt252, // Function selector
calldata: Array<felt252> // Encoded parameters
}
to:你想要交互的合约地址。对于代币兑换,这可能是 DEX 合约地址;对于授权,它将是代币合约地址。selector:你想在目标合约上调用的函数的唯一标识符。例如,要调用transfer,你需要将sn_keccak(’transfer’)作为 selector 传递。calldata:这是一个包含felt252值的数组,代表你传递给该函数的参数。
要使用 multicall 授权并执行兑换,你需要将以下两个调用传递给 calls 参数:
const calls: Call[] = [
{
contractAddress: TOKEN_ADDRESS, // to: token contract
entrypoint: "approve", // selector: approve function
calldata: [DEX_ADDRESS, amount] // calldata: spender and amount
},
{
contractAddress: DEX_ADDRESS, // to: DEX contract
entrypoint: "swap", // selector: swap function
calldata: [tokenA, tokenB, amount] // calldata: swap parameters
}
];
当你发送包含多个调用的交易时,__execute__ 函数会按顺序处理它们。如果所有调用都成功,__execute__ 会将每个调用的结果返回给调用者。如果任何调用失败,整个交易将回滚(revert);所有操作都不会生效。
使用 Starknet.js 执行 Multicall
现在让我们看看如何使用 starknet.js 执行 multicall。我们将通过将 RareTokens 存入 RareBank 来演示这一点,这两个合约是我们在前面章节中构建的。
RareToken(在 ERC20 章节 中介绍过)是一个标准的代币合约,而 RareBank(来自“Cross Contract Calls”文章)允许用户存取这些代币。存款需要两个步骤:
- 授权
RareBank使用你的代币 - 将代币存入
RareBank
如果没有 multicall,这将需要两笔独立的交易并分别支付 gas 费。有了 multicall,我们可以在单笔交易中原子化地执行这两个操作,因此你只需支付一笔交易费而不是两笔。
部署合约
在与 RareBank 和 RareToken 合约交互之前,我们需要确保两者都已部署在 Starknet 上。在本演示中,我们将使用 Starknet Sepolia。RareToken 合约已经在“Deploying Contracts”章节中部署。下面提供的地址具有不受限制的 mint 函数。它允许任何人铸造代币以测试 RareBank 合约。
RareToken 合约地址:
0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b
你可以在 Voyager 上查看它。
注意:如果你使用的是自己的代币,请将本教程中的 RareToken 地址替换为你自己代币的合约地址。
本章我们只需要部署 RareBank 合约。下面是 RareBank 合约的代码:
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;
}
#[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
}
#[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 user to this bank contract
// this calls the transfer_from function on the RareToken contract
let success = rare_token.transfer_from(caller, this_contract, amount);
assert!(success, "transfer failed");
// update the user'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 user 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 user
// 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()
}
}
}
要部署 RareBank 合约,我们首先声明其合约类,然后部署一个实例。
声明 RareBank:
sncast \
--account <ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name RareBank
替换:
<ACCOUNT_NAME>为你在 sncast 中的账户名<YOUR_API_KEY>为你在 Alchemy 上的 API 密钥
运行此命令后,你将在终端中收到一个 class hash;部署 RareBank 合约时需要用到它。

部署 RareBank
查看 RareBank 的构造函数:
#[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);
}
构造函数需要两个参数:
- owner: 将拥有
RareBank合约的地址 - rare_token_address: 代币合约地址
部署 RareBank 合约:
sncast \
--account <ACCOUNT_NAME> \
deploy \
--class-hash <CLASS_HASH> \
--arguments '<OWNER_ADDRESS>,<RARE_TOKEN_CONTRACT_ADDRESS>' \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY>
替换:
<ACCOUNT_NAME>为你的账户名<YOUR_API_KEY>为你的 Alchemy API 密钥<CLASS_HASH>为声明时获取的 class hash<OWNER_ADDRESS>为你的钱包地址<RARE_TOKEN_CONTRACT_ADDRESS>为你部署的代币合约地址或0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b
部署完成后,保存该合约地址;在实现 multicall 时会用到它。

设置项目
合约地址准备好后,我们将使用 starknet.js 以编程方式执行 multicall,从我们的账户签名并提交交易。运行以下命令克隆已设置好项目结构和配置的代码库:
git clone https://github.com/Sayrarh/starknet-multicall-demo.git
cd starknet-multicall-demo
然后安装依赖项并设置你的环境变量文件:
npm install
cp .env.example .env
打开 .env 并替换占位符值:
ACCOUNT_ADDRESS:你的 Starknet 账户地址PRIVATE_KEY:你账户的私钥ALCHEMY_API_KEY:你的 Alchemy API 密钥RARE_TOKEN_ADDRESS:使用提供的地址(已启用公共铸币)或你自己代币的合约地址RARE_BANK_ADDRESS:使用提供的地址或你自己的RareBank合约地址
编写 Multicall 代码
打开 src/index.ts 并首先设置基本的导入和配置:
import { Account, Call, CallData, RpcProvider, uint256 } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
const alchemyApiKey = process.env.ALCHEMY_API_KEY;
// Initialize provider
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/${alchemyApiKey}`,
});
// Connect your account
const account = new Account({
provider: provider,
address: process.env.ACCOUNT_ADDRESS!,
signer: process.env.PRIVATE_KEY!,
});
我们从 starknet.js 导入了必要的模块,包括 Call(定义合约交互)和 CallData(编码函数参数)。然后我们加载环境变量,设置 RPC provider 以通过 Alchemy 连接到 Starknet Sepolia,并初始化我们的账户。
铸造代币
在我们能够存入代币之前,我们的账户中需要有一些 RareTokens。mintTokens 函数会向已连接的账户铸造 100 个 RareTokens:
async function mintTokens() {
console.log("\n>> Minting RareTokens...");
const amount = 100n * 10n ** 18n;
const amountUint256 = uint256.bnToUint256(amount);
const result = await account.execute({
contractAddress: process.env.RARE_TOKEN_ADDRESS!,
entrypoint: "mint",
calldata: CallData.compile({
recipient: process.env.ACCOUNT_ADDRESS!,
amount: amountUint256,
}),
});
console.log("Transaction submitted!");
console.log(` Hash: ${result.transaction_hash}`);
console.log(` Voyager: https://sepolia.voyager.online/tx/${result.transaction_hash}`);
console.log("Waiting for confirmation...");
await provider.waitForTransaction(result.transaction_hash);
console.log("Successfully minted 100 RareTokens!\n");
}
脚本中包含了铸币操作,以确保你在存款前拥有代币。
构建调用 (Calls)
因为 multicall 仅仅是一个由独立 Call 对象组成的数组,当我们在 starknet.js 中指定 entrypoint(如 'approve')时,它会将其转换为一个选择器哈希,这与 Call 结构体中的 selector: felt252 字段相同。同样,starknet.js 中的 contractAddress 映射到 Call 结构体中的 to 字段。
让我们来构建所需的两个调用:
Call 1:授权 RareBank
approveCall 授权 RareBank 可以使用连接账户中的 10 个 RareTokens。
const amount = 10n * 10n ** 18n; // 10 tokens (accounting for 18 decimals)
const amountUint256 = uint256.bnToUint256(amount);
const approveCall: Call = {
contractAddress: process.env.RARE_TOKEN_ADDRESS!,
entrypoint: 'approve',
calldata: CallData.compile({
spender: process.env.RARE_BANK_ADDRESS!,
amount: amountUint256
})
};
请注意,我们乘以了 10¹⁸,因为 RareToken 有 18 位小数。
Call 2:存入 RareBank
depositCall 将已授权的代币存入 RareBank。它依赖于 Call 1 的授权在同一笔交易中已经执行。
const depositCall: Call = {
contractAddress: process.env.RARE_BANK_ADDRESS!,
entrypoint: 'deposit',
calldata: CallData.compile({
amount: amountUint256
})
};
执行 Multicall
现在让我们将两个调用结合起来,并在单笔交易中执行它们:
async function depositToRareBank() {
console.log(">> Executing multicall: Approve + Deposit");
const amount = 10n * 10n ** 18n;
const amountUint256 = uint256.bnToUint256(amount);
const multiCall: Call[] = [
{
contractAddress: process.env.RARE_TOKEN_ADDRESS!,
entrypoint: "approve",
calldata: CallData.compile({
spender: process.env.RARE_BANK_ADDRESS!,
amount: amountUint256,
}),
},
{
contractAddress: process.env.RARE_BANK_ADDRESS!,
entrypoint: "deposit",
calldata: CallData.compile({
amount: amountUint256,
}),
},
];
const result = await account.execute(multiCall);
console.log("Transaction submitted!");
console.log(` Hash: ${result.transaction_hash}`);
console.log(
` Voyager: https://sepolia.voyager.online/tx/${result.transaction_hash}`
);
console.log("Waiting for confirmation...");
await provider.waitForTransaction(result.transaction_hash);
console.log("Multicall executed successfully!");
console.log(
` Approved and deposited ${
amount / 10n ** 18n
} RareTokens in one transaction!\n`
);
}
这是完整的代码;将其复制到 src/index.ts 文件中:
import { Account, Call, CallData, RpcProvider, uint256 } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
const alchemyApiKey = process.env.ALCHEMY_API_KEY;
// Initialize provider
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
});
// initialize account
const account = new Account({
provider: provider,
address: process.env.ACCOUNT_ADDRESS!,
signer: process.env.PRIVATE_KEY!,
});
async function mintTokens() {
console.log("\n>> Minting RareTokens...");
const amount = 100n * 10n ** 18n;
const amountUint256 = uint256.bnToUint256(amount);
const result = await account.execute({
contractAddress: process.env.RARE_TOKEN_ADDRESS!,
entrypoint: "mint",
calldata: CallData.compile({
recipient: process.env.ACCOUNT_ADDRESS!,
amount: amountUint256,
}),
});
console.log("Transaction submitted!");
console.log(` Hash: ${result.transaction_hash}`);
console.log(` Voyager: https://sepolia.voyager.online/tx/${result.transaction_hash}`);
console.log("Waiting for confirmation...");
await provider.waitForTransaction(result.transaction_hash);
console.log("Successfully minted 100 RareTokens!\n");
}
async function depositToRareBank() {
console.log(">> Executing multicall: Approve + Deposit");
const amount = 10n * 10n ** 18n;
const amountUint256 = uint256.bnToUint256(amount);
const multiCall: Call[] = [
{
contractAddress: process.env.RARE_TOKEN_ADDRESS!,
entrypoint: "approve",
calldata: CallData.compile({
spender: process.env.RARE_BANK_ADDRESS!,
amount: amountUint256,
}),
},
{
contractAddress: process.env.RARE_BANK_ADDRESS!,
entrypoint: "deposit",
calldata: CallData.compile({
amount: amountUint256,
}),
},
];
const result = await account.execute(multiCall);
console.log("Transaction submitted!");
console.log(` Hash: ${result.transaction_hash}`);
console.log(` Voyager: https://sepolia.voyager.online/tx/${result.transaction_hash}`);
console.log("Waiting for confirmation...");
await provider.waitForTransaction(result.transaction_hash);
console.log("Multicall executed successfully!");
console.log(` Approved and deposited ${amount / 10n ** 18n} RareTokens in one transaction!\n`);
}
async function main() {
await mintTokens();
await depositToRareBank();
}
main()
在运行代码之前,请确保你拥有用于支付 Sepolia 网络 gas 费的 STRK 代币;可以从 Starknet Sepolia Faucet 获取。
使用以下命令运行代码:
npm start
你应该会看到类似以下的输出:

检查交易追踪 (Transaction Trace)
为了更好地了解在 multicall 期间发生了什么,让我们检查一下交易追踪。在 Voyager 上打开你的交易,导航到 Internal Calls 部分,并展开 __execute__ 函数;你将看到我们构建的包含两个调用的数组:

Call 1:Approve
to:0x3ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b(RareToken合约)selector:0x219209e083275171774dab1df80982e9df2096516f06319c5c6d71ae0a8480c(approve的哈希值)calldata:0x4c623c5b67ce693af795f5e49a468ba943ef8fbde7ba898f40df270bf96890e(RareBank地址;即 spender)0x8ac7230489e80000(十六进制的最低有效位;10 个代币)0x0(最高有效位)
回顾一下,在 Starknet 中,类型
u256被分成两部分(low:u128和 high:u128),这就是为什么你会看到该金额有两个值。
Call 2:Deposit
to:0x4c623c5b67ce693af795f5e49a468ba943ef8fbde7ba898f40df270bf96890e(RareBank合约)selector:0xc73f681176fc7b3f9693986fd7b14581e8d540519e27400e88b8713932be01(deposit的哈希值)calldata:0x8ac7230489e80000(十六进制的最低有效位;10 个代币)0x0(最高有效位)
在 Output(输出)部分,你还会看到调用树(call tree):

__execute__处理你的 multicall 数组- 对
RareToken进行approve调用 - 对
RareBank进行deposit调用- 内部调用
RareToken上的transfer_from
- 内部调用
检查 Events 选项卡以查看 Approval 和 DepositSuccessful 事件,确认操作已成功。