китаев.tech

Ускорение и отмена TX через replace-by-fee

Как заменить pending-транзакцию в Ethereum: тот же nonce, тот же отправитель и более высокая комиссия через ethers v6.

Что такое replace-by-fee

Replace-by-fee — это замена pending-транзакции новой транзакцией с тем же nonce и более высокой комиссией.

Так можно ускорить зависшую транзакцию или отменить её, пока она ещё не попала в блок.


Отменяем pending-транзакцию

Запустите локальную сеть Hardhat в отдельном терминале:

bash
npx hardhat node

Затем выполните TypeScript-код:

typescript
import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");

// Первый тестовый аккаунт Hardhat. Не используйте этот ключ в mainnet.
const sender = new ethers.Wallet(
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
  provider,
);

// Второй тестовый аккаунт Hardhat.
const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";

async function getBaseFeePerGas() {
  const block = await provider.getBlock("latest");

  if (block?.baseFeePerGas == null) {
    throw new Error("Сеть не поддерживает EIP-1559");
  }

  return block.baseFeePerGas;
}

try {
  await provider.send("evm_setAutomine", [false]);

  const nonce = await provider.getTransactionCount(sender.address, "pending");
  const baseFeePerGas = await getBaseFeePerGas();

  const originalMaxPriorityFeePerGas = ethers.parseUnits("1", "gwei");
  const originalMaxFeePerGas = baseFeePerGas * 2n + originalMaxPriorityFeePerGas;

  const originalTx = await sender.sendTransaction({
    to: recipient,
    value: ethers.parseEther("1"),
    nonce,
    type: 2,
    maxFeePerGas: originalMaxFeePerGas,
    maxPriorityFeePerGas: originalMaxPriorityFeePerGas,
  });

  console.log("Original hash:", originalTx.hash);
  console.log("Original nonce:", originalTx.nonce);
  console.log("Original max fee:", ethers.formatUnits(originalMaxFeePerGas, "gwei"), "gwei");
  console.log("Original receipt:", await provider.getTransactionReceipt(originalTx.hash));

  const cancelMaxPriorityFeePerGas = ethers.parseUnits("3", "gwei");
  const cancelMaxFeePerGas = baseFeePerGas * 4n + cancelMaxPriorityFeePerGas;

  const cancelTx = await sender.sendTransaction({
    to: sender.address,
    value: 0n,
    nonce,
    type: 2,
    maxFeePerGas: cancelMaxFeePerGas,
    maxPriorityFeePerGas: cancelMaxPriorityFeePerGas,
  });

  console.log("Cancel hash:", cancelTx.hash);
  console.log("Cancel nonce:", cancelTx.nonce);
  console.log("Cancel max fee:", ethers.formatUnits(cancelMaxFeePerGas, "gwei"), "gwei");

  await provider.send("evm_mine", []);

  const cancelReceipt = await cancelTx.wait();
  const originalReceipt = await provider.getTransactionReceipt(originalTx.hash);

  if (!cancelReceipt || cancelReceipt.status !== 1) {
    throw new Error("Cancel-транзакция не была успешно выполнена");
  }

  console.log("Cancel included in block:", cancelReceipt.blockNumber);
  console.log("Cancel gas used:", cancelReceipt.gasUsed.toString());
  console.log("Original receipt after replacement:", originalReceipt);
} finally {
  await provider.send("evm_setAutomine", [true]);
}

Код создаёт pending-перевод 1 ETH, а затем заменяет его self-transfer транзакцией с тем же nonce. В блок попадёт только новая транзакция.


Как это работает

evm_setAutomine — выключает автоматический майнинг в Hardhat, чтобы первая транзакция осталась pending.

provider.getTransactionCount(sender.address, "pending") — берёт следующий nonce с учётом уже pending-транзакций.

nonce — ключ к замене. Новая транзакция должна иметь тот же nonce и того же отправителя.

maxFeePerGas и maxPriorityFeePerGas — должны быть выше, чем в старой pending-транзакции.

to: sender.address и value: 0n — делают отмену: вместо исходного перевода отправитель отправляет ноль ETH самому себе.

provider.getTransactionReceipt(originalTx.hash) — после замены не возвращает receipt, потому что старая транзакция не попала в блок.


Ускорение vs отмена

Ускорение и отмена используют один механизм:

text
тот же from
тот же nonce
более высокая комиссия

Разница только в содержимом новой транзакции.

ЦельЧто отправить вместо старой TX
Ускоритьту же операцию: тот же to, value, data
Отменитьself-transfer: to = from, value = 0

Если новая транзакция попадёт в блок, старая станет недействительной. Две транзакции с одним nonce от одного адреса не могут выполниться обе.


Почему нужна более высокая комиссия

RPC-нода и валидаторы не обязаны принимать замену с такой же ценой gas.

Обычно новая транзакция должна заметно повысить:

text
maxFeePerGas
maxPriorityFeePerGas

Если поднять только maxFeePerGas, но оставить низкий maxPriorityFeePerGas, валидатору может быть всё равно невыгодно включать транзакцию. Для ускорения важны именно чаевые.


Когда replace-by-fee уже не поможет

Replace-by-fee работает только для pending-транзакций.

Если старая транзакция уже получила receipt, её нельзя отменить. Можно отправить новую обратную транзакцию, но она не откатит уже выполненное действие.

text
pending   → можно заменить
confirmed → нельзя заменить
failed    → нельзя заменить, gas уже потрачен

Частые ошибки

Менять nonce → это создаст новую транзакцию в очереди, а не заменит старую.

Поднимать только gasLimit → replace-by-fee требует более высокую цену gas, а не больший лимит.

Пытаться отменить confirmed-транзакцию → после receipt транзакция уже в блоке, replace-by-fee не работает.


Что дальше

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