El multicall nativo es la capacidad de Starknet de agrupar múltiples llamadas a contratos en una sola transacción atómica. Algunos flujos de trabajo de aplicaciones descentralizadas requieren firmar múltiples transacciones en secuencia. Los intercambios de tokens (swaps) son un ejemplo común. Cuando deseas convertir el token A en el token B en un DEX tradicional, tienes que firmar múltiples transacciones:
- firmas para aprobar que el contrato del DEX gaste tu token A
- luego firmas nuevamente para intercambiar el token A por el token B
Ese es un proceso de transacción de dos pasos, con tarifas de gas pagadas cada vez que firmas. Más importante aún, estas dos transacciones no tienen garantía de atomicidad. Si la aprobación tiene éxito pero el intercambio falla, el contrato del DEX retiene un acceso de gasto a tus tokens indefinidamente. Esa aprobación pendiente crea una superficie de ataque: si el contrato del DEX es explotado más tarde, un atacante puede usar el permiso transferFrom existente del contrato para vaciar la cantidad aprobada sin ninguna acción de tu parte.
Starknet elimina estos problemas al permitir que múltiples llamadas a contratos se agrupen en una sola transacción atómica. Firmas una vez y pagas tarifas de gas una vez.
Esta capacidad está integrada en el protocolo de Starknet a través de su arquitectura nativa de abstracción de cuentas (account abstraction). En este artículo, exploraremos cómo funciona el multicall y lo demostraremos ejecutando una aprobación y un depósito de tokens como una sola transacción atómica usando starknet.js.
Cómo funciona el multicall internamente
Como se cubrió en el capítulo anterior, Starknet implementa la Abstracción de Cuentas nativa donde cada cuenta es un contrato inteligente. Cada contrato de cuenta tiene una función __execute__ que el protocolo llama cuando envías una transacción. Toma un array de llamadas para ejecutar:
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>>
El parámetro calls: Array<Call> es lo que hace posible el multicall. Permite a la función ejecutar una o múltiples operaciones en una sola transacción.
Cada Call en ese array se define como:
#[derive(Drop, Serde, Debug)]
struct Call {
to: ContractAddress, // Target contract address
selector: felt252, // Function selector
calldata: Array<felt252> // Encoded parameters
}
to: La dirección del contrato con el que deseas interactuar. Para un intercambio de tokens, esta podría ser la dirección del contrato del DEX, o para una aprobación, sería la dirección del contrato del token.selector: Un identificador único para la función que deseas llamar en el contrato de destino. Por ejemplo, para llamar atransfer, pasassn_keccak(’transfer’)como el selector.calldata: Este es un array de valoresfelt252que representan los argumentos que estás pasando a la función
Para aprobar y ejecutar un intercambio usando multicall, pasas las siguientes dos llamadas al parámetro 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
}
];
Cuando envías una transacción con múltiples llamadas, la función __execute__ las procesa secuencialmente. Si todas las llamadas tienen éxito, __execute__ devuelve el resultado de cada llamada al invocador. Si alguna llamada falla, toda la transacción se revierte; ninguna de las operaciones surte efecto.
Ejecutando Multicalls con Starknet.js
Ahora veamos cómo ejecutar multicalls usando starknet.js. Demostraremos esto depositando RareTokens en RareBank, dos contratos que hemos construido en capítulos anteriores.
RareToken (cubierto en el capítulo de ERC20) es un contrato de token estándar, mientras que RareBank (del artículo “Cross Contract Calls”) permite a los usuarios depositar y retirar estos tokens. Depositar requiere dos pasos:
- Aprobar a
RareBankpara gastar tus tokens - Depositar los tokens en
RareBank
Sin multicall, esto requiere dos transacciones separadas y tarifas de gas pagadas por cada una. Con multicall, podemos ejecutar ambas operaciones atómicamente en una sola transacción, por lo que pagas una tarifa de transacción en lugar de dos.
Desplegando los Contratos
Antes de interactuar con los contratos RareBank y RareToken, debemos asegurarnos de que ambos estén desplegados en Starknet. Para esta demostración, usaremos Starknet Sepolia. El contrato RareToken ya se desplegó en el capítulo “Deploying Contracts”. La dirección proporcionada a continuación tiene una función mint sin restricciones. Permite a cualquiera acuñar tokens para probar el contrato RareBank.
Dirección del contrato RareToken:
0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b
Puedes verlo en Voyager.
Nota: Si estás usando tu propio token, reemplaza la dirección de RareToken a lo largo de este tutorial con la dirección de tu contrato de token.
Solo necesitamos desplegar el contrato RareBank para este capítulo. A continuación se muestra el contrato 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()
}
}
}
Para desplegar el contrato RareBank, primero declaramos su clase de contrato, luego desplegamos una instancia.
Declarando RareBank:
sncast \
--account <ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name RareBank
Reemplaza:
<ACCOUNT_NAME>con el nombre de tu cuenta de sncast<YOUR_API_KEY>con tu clave API de Alchemy.
Después de ejecutar este comando, recibirás un class hash en tu terminal; será necesario para el despliegue del contrato RareBank.

Desplegando RareBank
Observando el constructor de 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);
}
El constructor espera dos parámetros:
- owner: La dirección que será propietaria del contrato
RareBank - rare_token_address: La dirección del contrato del token
Despliega el contrato 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>
Reemplaza:
<ACCOUNT_NAME>con el nombre de tu cuenta<YOUR_API_KEY>con tu clave API de Alchemy<CLASS_HASH>con el class hash de la declaración<OWNER_ADDRESS>con la dirección de tu billetera<RARE_TOKEN_CONTRACT_ADDRESS>con la dirección de tu contrato de token desplegado o0x03ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b
Después del despliegue, guarda la dirección del contrato; será necesaria para la implementación del multicall.

Configurando el Proyecto
Con las direcciones de los contratos listas, usaremos starknet.js para ejecutar el multicall programáticamente, firmando y enviando la transacción desde nuestra cuenta. Ejecuta el siguiente comando para clonar el repositorio con la estructura del proyecto y la configuración ya establecidas:
git clone https://github.com/Sayrarh/starknet-multicall-demo.git
cd starknet-multicall-demo
Luego instala las dependencias y configura tu archivo de entorno:
npm install
cp .env.example .env
Abre .env y reemplaza los valores de marcador de posición:
ACCOUNT_ADDRESS: La dirección de tu cuenta de StarknetPRIVATE_KEY: La clave privada de tu cuentaALCHEMY_API_KEY: Tu clave API de AlchemyRARE_TOKEN_ADDRESS: Usa la dirección proporcionada (acuñación pública habilitada) o la dirección de tu propio contrato de tokenRARE_BANK_ADDRESS: Usa la dirección proporcionada o la dirección de tu propio contratoRareBank
Escribiendo el Código del Multicall
Abre src/index.ts y comienza configurando las importaciones básicas y la configuración:
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!,
});
Importamos los módulos necesarios de starknet.js, incluyendo Call (define las interacciones del contrato) y CallData (codifica los parámetros de la función). Luego cargamos las variables de entorno, configuramos el proveedor RPC para conectarnos a Starknet Sepolia a través de Alchemy e inicializamos nuestra cuenta.
Acuñando Tokens
Antes de que podamos depositar tokens, necesitamos algunos RareTokens en nuestra cuenta. La función mintTokens acuña 100 RareTokens a la cuenta conectada:
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");
}
La acuñación está incluida en el script para garantizar que tengas tokens antes de depositar.
Construyendo las Llamadas
Dado que un multicall es simplemente un array de objetos Call individuales, cuando especificamos un entrypoint (como 'approve') en starknet.js, este lo convierte en un hash de selector, el mismo campo selector: felt252 en la estructura Call. De manera similar, contractAddress en starknet.js se asigna al campo to en la estructura Call.
Construyamos las dos llamadas que necesitamos:
Call 1: Aprobar RareBank
approveCall aprueba a RareBank para gastar 10 RareTokens de la cuenta conectada.
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
})
};
Ten en cuenta que multiplicamos por 10¹⁸ porque RareToken tiene 18 decimales.
Call 2: Depositar en RareBank
depositCall deposita los tokens aprobados en RareBank. Depende de que la aprobación de Call 1 ya se haya ejecutado dentro de la misma transacción.
const depositCall: Call = {
contractAddress: process.env.RARE_BANK_ADDRESS!,
entrypoint: 'deposit',
calldata: CallData.compile({
amount: amountUint256
})
};
Ejecutando el Multicall
Ahora combinemos ambas llamadas y ejecutémoslas en una sola transacció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`
);
}
Aquí está el código completo; cópialo en el archivo 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()
Antes de ejecutar el código, asegúrate de tener tokens STRK para las tarifas de gas en Sepolia; consíguelos en el Starknet Sepolia Faucet.
Ejecuta el código usando:
npm start
Deberías ver una salida similar a esta:

Inspeccionando la Traza de la Transacción
Para entender mejor qué sucede durante el multicall, examinemos la traza de la transacción. Abre tu transacción en Voyager, navega a la sección de Llamadas Internas (Internal Calls) y expande la función __execute__; verás el array de las dos llamadas que construimos:

Call 1: Aprobar
to:0x3ec6283d9c7c8936991fad6523e01b60ad2bb092aa489087a3376c2ade7c09b(contratoRareToken)selector:0x219209e083275171774dab1df80982e9df2096516f06319c5c6d71ae0a8480c(el hash deapprove)calldata:0x4c623c5b67ce693af795f5e49a468ba943ef8fbde7ba898f40df270bf96890e(dirección deRareBank; el gastador)0x8ac7230489e80000(bits menos significativos en hex; 10 tokens)0x0(bits más significativos)
Recuerda que en Starknet, el tipo
u256se divide en dos partes (baja:u128y alta:u128), razón por la cual ves dos valores para la cantidad.
Call 2: Depositar
to:0x4c623c5b67ce693af795f5e49a468ba943ef8fbde7ba898f40df270bf96890e(contratoRareBank)selector:0xc73f681176fc7b3f9693986fd7b14581e8d540519e27400e88b8713932be01(el hash dedeposit)calldata:0x8ac7230489e80000(bits menos significativos en hex; 10 tokens)0x0(bits más significativos)
En la sección de Salida (Output), también verás el árbol de llamadas (call tree):

__execute__procesa tu array de multicall- llamada
approveaRareToken - llamada
depositaRareBank- Internamente llama a
transfer_fromenRareToken
- Internamente llama a
Revisa la pestaña de Eventos para ver los eventos Approval y DepositSuccessful que confirman el éxito.