китаев.tech

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-контракта.


Создаём проект

bash
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:

bash
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:

solidity
// 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:

typescript
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 минут");

Запустите:

bash
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.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.