Retryable tickets на Arbitrum
Как отправить L1→L2 вызов в Arbitrum через retryable ticket: задеплоить L2-контракт, закодировать calldata и выполнить функцию из Ethereum Sepolia.
Что такое retryable ticket
Retryable ticket — это L1→L2 сообщение, которое создаётся в Ethereum и выполняется в Arbitrum. Оно нужно, когда с L1 нужно не просто перевести ETH, а вызвать функцию контракта на L2.
Обычный ETH bridge вызывает depositEth() и зачисляет баланс. Retryable ticket вызывает createRetryableTicket(...) и передаёт calldata для L2-контракта.
Создаём проект
mkdir arbitrum-retryable-ticket
cd arbitrum-retryable-ticket
npm init -y
npm pkg set type="module"
npm install ethers dotenv solc
npm install --save-dev typescript tsx @types/node
mkdir contracts srcСоздайте файл .env:
PRIVATE_KEY="0x..."
PARENT_RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"
CHILD_RPC_URL="https://sepolia-rollup.arbitrum.io/rpc"На этом адресе нужен ETH в двух сетях: в Ethereum Sepolia для создания retryable ticket и в Arbitrum Sepolia для деплоя L2-контракта.
Пишем L2-контракт
Создайте файл contracts/RetryableReceiver.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RetryableReceiver {
string public message;
address public lastSender;
event MessageChanged(address indexed sender, string newMessage);
constructor(string memory initialMessage) {
message = initialMessage;
lastSender = msg.sender;
}
function setMessage(string calldata newMessage) external payable {
message = newMessage;
lastSender = msg.sender;
emit MessageChanged(msg.sender, newMessage);
}
}Контракт открыт намеренно: retryable ticket приходит не как обычный вызов кошелька. В lastSender будет адрес, который Arbitrum подставит для L1→L2 сообщения.
Создаём retryable ticket
Создайте файл src/create-retryable.ts:
import "dotenv/config";
import { readFileSync } from "node:fs";
import { setTimeout } from "node:timers/promises";
import { ethers } from "ethers";
import solc from "solc";
const PARENT_CHAIN_ID = 11155111n;
const CHILD_CHAIN_ID = 421614n;
const PARENT_RPC_URL =
process.env.PARENT_RPC_URL ?? "https://ethereum-sepolia-rpc.publicnode.com";
const CHILD_RPC_URL =
process.env.CHILD_RPC_URL ?? "https://sepolia-rollup.arbitrum.io/rpc";
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
throw new Error("Добавьте PRIVATE_KEY в .env");
}
const ARBITRUM_SEPOLIA_INBOX = "0xaAe2B02A3654575973752e5a7A6A329705a2ae21";
const INBOX_ABI = [
"function createRetryableTicket(address to,uint256 l2CallValue,uint256 maxSubmissionCost,address excessFeeRefundAddress,address callValueRefundAddress,uint256 gasLimit,uint256 maxFeePerGas,bytes data) external payable returns (uint256)",
];
type SolcOutput = {
contracts: Record<
string,
Record<
string,
{
abi: ethers.InterfaceAbi;
evm: { bytecode: { object: string } };
}
>
>;
errors?: Array<{ severity: string; formattedMessage: string }>;
};
async function assertNetwork(
provider: ethers.JsonRpcProvider,
expectedChainId: bigint,
name: string,
) {
const network = await provider.getNetwork();
if (network.chainId !== expectedChainId) {
throw new Error(
`${name}: ожидался chainId ${expectedChainId}, получен ${network.chainId}`,
);
}
}
function compileReceiver() {
const source = readFileSync("contracts/RetryableReceiver.sol", "utf8");
const input = {
language: "Solidity",
sources: {
"RetryableReceiver.sol": {
content: source,
},
},
settings: {
outputSelection: {
"*": {
"*": ["abi", "evm.bytecode.object"],
},
},
},
};
const output = JSON.parse(solc.compile(JSON.stringify(input))) as SolcOutput;
for (const error of output.errors ?? []) {
if (error.severity === "error") {
throw new Error(error.formattedMessage);
}
}
return output.contracts["RetryableReceiver.sol"].RetryableReceiver;
}
const parentProvider = new ethers.JsonRpcProvider(PARENT_RPC_URL);
const childProvider = new ethers.JsonRpcProvider(CHILD_RPC_URL);
await assertNetwork(parentProvider, PARENT_CHAIN_ID, "Ethereum Sepolia");
await assertNetwork(childProvider, CHILD_CHAIN_ID, "Arbitrum Sepolia");
const parentWallet = new ethers.Wallet(privateKey, parentProvider);
const childWallet = new ethers.Wallet(privateKey, childProvider);
const refundAddress = await parentWallet.getAddress();
const receiverArtifact = compileReceiver();
const receiverFactory = new ethers.ContractFactory(
receiverArtifact.abi,
`0x${receiverArtifact.evm.bytecode.object}`,
childWallet,
);
const receiver = await receiverFactory.deploy("Сообщение до retryable ticket");
await receiver.waitForDeployment();
const receiverAddress = await receiver.getAddress();
console.log("L2 receiver:", receiverAddress);
console.log(`L2 explorer: https://sepolia.arbiscan.io/address/${receiverAddress}`);
const nextMessage = "Сообщение пришло из Ethereum Sepolia";
const calldata = receiver.interface.encodeFunctionData("setMessage", [
nextMessage,
]);
const l2CallValue = 0n;
const gasLimit = 200_000n;
const feeData = await childProvider.getFeeData();
const maxFeePerGas = (feeData.maxFeePerGas ?? ethers.parseUnits("0.1", "gwei")) * 2n;
const maxSubmissionCost = ethers.parseEther("0.0002");
const deposit = l2CallValue + maxSubmissionCost + gasLimit * maxFeePerGas;
console.log("Retryable deposit:", ethers.formatEther(deposit), "ETH");
const inbox = new ethers.Contract(
ARBITRUM_SEPOLIA_INBOX,
INBOX_ABI,
parentWallet,
);
const tx = await inbox.createRetryableTicket(
receiverAddress,
l2CallValue,
maxSubmissionCost,
refundAddress,
refundAddress,
gasLimit,
maxFeePerGas,
calldata,
{
value: deposit,
},
);
console.log("L1 retryable tx:", tx.hash);
const receipt = await tx.wait();
if (!receipt) {
throw new Error("Транзакция не вернула receipt");
}
console.log("L1 confirmed in block:", receipt.blockNumber);
console.log(`L1 explorer: https://sepolia.etherscan.io/tx/${tx.hash}`);
for (let attempt = 1; attempt <= 40; attempt++) {
const message = await receiver.message();
if (message === nextMessage) {
const lastSender = await receiver.lastSender();
console.log("L2 message:", message);
console.log("L2 msg.sender:", lastSender);
process.exit(0);
}
console.log(`Ждём выполнение retryable ticket... попытка ${attempt}/40`);
await setTimeout(30_000);
}
throw new Error("Retryable ticket не выполнился за 20 минут");Запустите:
npx tsx src/create-retryable.tsСкрипт задеплоит L2-контракт, создаст retryable ticket в Ethereum Sepolia и будет ждать, пока сообщение изменится в Arbitrum Sepolia.
Как это работает
createRetryableTicket(...) — функция Inbox-контракта, которая создаёт L1→L2 сообщение с calldata.
to — адрес L2-контракта, который должен получить вызов. В примере это RetryableReceiver.
l2CallValue — сколько ETH передать в L2-вызов. Здесь 0, потому что мы меняем строку без отправки ETH.
maxSubmissionCost — ETH на публикацию retryable ticket. Если часть не потратится, Arbitrum вернёт остаток на refund-адрес.
gasLimit и maxFeePerGas — лимит и цена газа для выполнения L2-вызова.
deposit — общий ETH, который отправляется вместе с L1-транзакцией: l2CallValue + maxSubmissionCost + gasLimit * maxFeePerGas.
data — calldata для функции setMessage(string). ethers кодирует его через ABI контракта.
Retryable ticket vs bridge ETH
| Механизм | Что делает | Когда использовать |
|---|---|---|
depositEth() | Зачисляет ETH на L2-адрес | Нужно пополнить баланс в Arbitrum |
createRetryableTicket() | Вызывает L2-контракт с calldata | Нужно выполнить функцию на L2 из L1 |
Bridge ETH — частный случай L1→L2 сообщения. Retryable ticket даёт больше контроля: можно выбрать target, calldata, call value, gas и refund-адреса.
Почему ticket называется retryable
После создания Arbitrum пытается автоматически выполнить ticket на L2. Если выполнение не удалось из-за недостатка газа или временной ошибки, ticket можно повторить вручную, пока он не истёк.
Это важно для cross-chain UX: L1-транзакция уже подтверждена, но L2-вызов всё ещё можно довести до выполнения без повторной L1-транзакции.
Частые ошибки
Слишком маленький deposit → ticket создастся некорректно или не сможет оплатить L2-выполнение. Считайте deposit как сумму l2CallValue, maxSubmissionCost и L2 gas.
Слишком маленький gasLimit → ticket может создаться, но L2-вызов не выполнится. Увеличьте gasLimit для сложных функций.
Ожидаете обычный msg.sender на L2 → L1→L2 вызов приходит через системный механизм Arbitrum, а не как прямая транзакция EOA в L2.
Нет ETH в одной из сетей → для этого примера нужен ETH в Arbitrum Sepolia на деплой L2-контракта и ETH в Ethereum Sepolia на создание retryable ticket.
Что дальше
- Bridge ETH на Arbitrum — пополнить L2-адрес перед деплоем и тестами
- Настройка и деплой на Arbitrum — задеплоить контракт в Arbitrum Sepolia через Hardhat
- Batch транзакции через Multicall — сравнить L2-вызов из L1 с обычным batch внутри одной сети