Состояния гонки в делегировании энергии: как мы их решили
Это история об ошибке, которая стоила 19 TRX за транзакцию вместо того, чтобы сэкономить 1.43 TRX. Это история о состоянии гонки, которое выглядело правильно в тестах, работало на тестнете и проявилось только в условиях мейннета. И это история о решении — механизме опроса, который ждет подтверждения в сети перед тем, как позволить транзакции продолжиться.
Предпосылки
MERX покупает энергию у провайдеров от имени пользователей. Процесс в теории прямолинеен:
- Пользователь запрашивает энергию для своего адреса
- MERX выбирает самого дешевого провайдера
- Провайдер делегирует энергию на адрес пользователя
- Пользователь выполняет свою транзакцию с делегированной энергией
- Транзакция потребляет арендованную энергию вместо сжигания TRX
Критическое слово здесь — "до". Транзакция пользователя должна выполниться после подтверждения делегирования в сети. Если транзакция транслируется до того, как делегирование прибудет, транзакция все равно будет успешной — но она будет сжигать TRX по протокольному курсу сжигания вместо потребления арендованной энергии. Пользователь платит дважды: один раз за аренду и один раз через сжигание TRX.
Ошибка
Первоначальная реализация следовала последовательному паттерну:
async function executeWithEnergy(
targetAddress: string,
energyAmount: number,
transactionFn: () => Promise<string>
): Promise<string> {
// Step 1: Buy energy
const order = await merx.createOrder({
energy_amount: energyAmount,
target_address: targetAddress,
duration: '1h'
});
// Step 2: Wait for order confirmation from MERX
await waitForOrderStatus(order.id, 'FILLED');
// Step 3: Execute the transaction
const txHash = await transactionFn();
return txHash;
}
Это выглядит правильно. Купить энергию, ждать, пока ордер будет заполнен, затем выполнить транзакцию. Функция waitForOrderStatus опрашивала API MERX до тех пор, пока статус ордера не изменялся на FILLED.
Проблема в том, что означает "FILLED". В системе MERX ордер отмечается как FILLED когда провайдер подтверждает, что инициировал делегирование. Провайдер возвращает успешный ответ: "Я отправил транзакцию делегирования в сеть TRON."
Но "отправлена" не означает "подтверждена". Делегирование — это транзакция TRON, которая должна быть включена в блок и обработана сетью. Между отправкой и подтверждением есть окно — обычно 3-6 секунд, но иногда дольше во время перегрузки сети.
В течение этого окна целевой адрес еще не имеет делегированную энергию.
Что произошло на мейннете
Ошибка проявилась ровно так, как предсказала состояние гонки:
Временная шкала:
T+0.0s Ордер создан, отправлен провайдеру
T+0.3s Провайдер отвечает: TX делегирования отправлена
T+0.4s Статус ордера -> FILLED
T+0.5s Транзакция пользователя транслируется (передача USDT)
T+0.6s TX пользователя включена в блок N
T+3.2s TX делегирования включена в блок N+1
Результат:
- TX пользователя в блоке N: делегирование еще не существует
- Потребленная энергия: 0 (делегирование еще не активно)
- Сожжено TRX: 65,000 * 420 SUN = 27,300,000 SUN = 27.3 TRX
- Стоимость аренды энергии: 1,820,000 SUN = 1.82 TRX
- Общая стоимость: 29.12 TRX (аренда + сжигание)
- Ожидаемая стоимость: 1.82 TRX (только аренда)
Пользователь заплатил 29.12 TRX вместо 1.82 TRX. Аренда энергии была потрачена впустую, потому что делегирование прибыло на один блок позже.
Числа
На мейннете TRON каждый блок занимает примерно 3 секунды. Транзакция делегирования, отправленная провайдером, проходит через тот же процесс включения в блок, что и любая другая транзакция. Если транзакция пользователя и транзакция делегирования отправлены в течение нескольких секунд друг от друга, они могут оказаться в разных блоках — без гарантии порядка.
Разница в стоимости очень велика:
Без делегирования (сжигание TRX):
65,000 энергии * 420 SUN/энергия = 27,300,000 SUN = 27.3 TRX
С делегированием (аренда):
65,000 энергии * 28 SUN/энергия = 1,820,000 SUN = 1.82 TRX
Потраченные зря деньги за одно возникновение состояния гонки:
27.3 + 1.82 = 29.12 TRX общая стоимость
vs.
1.82 TRX ожидаемая стоимость
Переплата: 27.3 TRX за инцидент
В плохой день это состояние гонки могло бы срабатывать в 10-20% ордеров, где клиент выполнил сразу после получения статуса FILLED.
Почему тестирование это не поймало
Поведение тестнета
На тестнете Shasta время блоков похоже на мейннет, но перегрузка сети минимальна. Транзакции делегирования обычно включаются в следующий блок. Окно между "отправкой" и "подтверждением" было постоянно менее 3 секунд на тестнете, и наш тестовый набор имел встроенную 2-секундную задержку между этапами, которая скрывала состояние гонки.
Последовательное тестирование
Наши интеграционные тесты были последовательными. Один тест покупал энергию, ждал, выполнял транзакцию и проверял. Никогда не было параллельной нагрузки, никогда не было гонки между делегированием и выполнением, никогда давления на тайминг, который создавал мейннет.
Ловушка статуса ордера
Самый коварный аспект: статус ордера был технически правилен. Ордер был FILLED — провайдер принял и инициировал делегирование. Ошибка была не в отслеживании статуса. Она была в предположении, что FILLED означает "энергия доступна в сети".
Решение
Решение состоит из двух частей: шага проверки, который проверяет ресурсы целевого адреса в сети, и цикла опроса, который ждет до тех пор, пока делегирование действительно не будет подтверждено.
Часть 1: check_address_resources
TRON предоставляет API для проверки ресурсов (энергия и bandwidth) в данный момент доступных для любого адреса:
GET https://api.trongrid.io/wallet/getaccountresource
Это возвращает текущий лимит энергии, использованную энергию, лимит bandwidth и использованный bandwidth для адреса. Критически важно, что это отражает состояние в сети — если делегирование было подтверждено, лимит энергии это отразит. Если делегирование все еще ожидает, лимит энергии это не будет включать.
Часть 2: опрашивать до подтверждения
Решение заменяет одну проверку "ждать FILLED" циклом опроса, который проверяет ресурсы в сети:
async function executeWithEnergy(
targetAddress: string,
energyAmount: number,
transactionFn: () => Promise<string>
): Promise<string> {
// Step 1: Check baseline resources
const baseline = await checkAddressResources(targetAddress);
const baselineEnergy = baseline.energy_limit - baseline.energy_used;
// Step 2: Buy energy
const order = await merx.createOrder({
energy_amount: energyAmount,
target_address: targetAddress,
duration: '1h'
});
// Step 3: Wait for order to be filled by the provider
await waitForOrderStatus(order.id, 'FILLED');
// Step 4: Poll on-chain resources until delegation is confirmed
const confirmed = await pollUntilDelegationConfirmed(
targetAddress,
baselineEnergy,
energyAmount,
{ maxAttempts: 15, intervalMs: 2000 }
);
if (!confirmed) {
throw new Error(
'Delegation not confirmed on-chain within timeout. ' +
'Do not execute transaction -- energy may not be available.'
);
}
// Step 5: Execute the transaction (delegation is confirmed on-chain)
const txHash = await transactionFn();
return txHash;
}
async function pollUntilDelegationConfirmed(
address: string,
baselineEnergy: number,
expectedIncrease: number,
options: { maxAttempts: number; intervalMs: number }
): Promise<boolean> {
for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
const resources = await checkAddressResources(address);
const currentEnergy = resources.energy_limit - resources.energy_used;
const increase = currentEnergy - baselineEnergy;
if (increase >= expectedIncrease * 0.95) {
// Allow 5% tolerance for rounding
return true;
}
await sleep(options.intervalMs);
}
return false;
}
async function checkAddressResources(
address: string
): Promise<{ energy_limit: number; energy_used: number }> {
const response = await fetch(
'https://api.trongrid.io/wallet/getaccountresource',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: address,
visible: true
})
}
);
const data = await response.json();
return {
energy_limit: data.EnergyLimit || 0,
energy_used: data.EnergyUsed || 0
};
}
Почему 2-секундные интервалы
Интервал опроса составляет 2 секунды. Это выбрано на основе 3-секундного времени блока TRON:
- Опрашивание каждую 1 секунду приводит к множеству избыточных проверок между блоками
- Опрашивание каждые 3 секунды может привести к пропуску некоторых блоков (смещение часов, задержка сети)
- Опрашивание каждые 2 секунды обеспечивает проверку в каждом цикле блока
Почему 15 попыток
15 попыток с интервалом 2 секунды = максимальное время ожидания 30 секунд. На практике делегирование подтверждается в течение 1-2 опросов (3-6 секунд).Timeout 30 секунд обрабатывает экстремальные случаи:
- Перегрузка сети, задерживающая включение блока
- Провайдер, отправляющий транзакцию делегирования позже
- Временное отставание RPC узла
Если делегирование не подтверждено после 30 секунд, что-то действительно не так, и безопаснее выйти с ошибкой, чем продолжить и сжечь TRX.
Реальные данные мейннета
После развертывания решения мы измеряли поведение опроса в течение одной недели трафика в production:
Время подтверждения делегирования:
Подтверждено при первом опросе (0-2s): 12%
Подтверждено при втором опросе (2-4s): 61%
Подтверждено при третьем опросе (4-6s): 22%
Подтверждено при четвертом опросе (6-8s): 4%
Подтверждено при пятом+ опросе (8s+): 1%
Timeout (не подтверждено в 30s): 0.04%
Среднее время от FILLED ордера до подтверждения в сети: 3.1 секунд
Медиана: 2.8 секунд
P99: 8.2 секунд
Данные подтверждают окно состояния гонки: в среднем 3.1 секунды между ответом "FILLED" провайдера и подтверждением в сети. Без исправления опроса любая транзакция, выполненная в течение этого окна, сожгла бы TRX.
Влияние на стоимость
До исправления (30 дней):
Ордеры, затронутые состоянием гонки: ~180
Средняя переплата за инцидент: ~19 TRX
Общая стоимость состояний гонки: ~3,420 TRX
После исправления (30 дней):
Инциденты состояния гонки: 0
Сбои timeout (делегирование никогда не подтверждено): 2
Оба перехвачены timeout, транзакция не выполнена
Ордеры возмещены автоматически
Исправление полностью устранило состояние гонки. Два сбоя timeout были подлинными проблемами на стороне провайдера, когда транзакция делегирования никогда не была отправлена — ровно те случаи, когда вы хотите выйти с ошибкой вместо того, чтобы продолжить.
Уроки, которые мы извлекли
"Отправлена" это не "подтверждена"
Это центральный урок. В любой системе, взаимодействующей с блокчейном, всегда есть разрыв между отправкой транзакции и подтверждением этой транзакции в сети. Любая логика, которая рассматривает отправку как подтверждение, в конечном итоге выйдет из строя.
Проверяйте цепь, не сервис
Провайдер говорит, что делегирование сделано. MERX говорит, что ордер заполнен. Но ни одно из этих утверждений не означает, что делегирование существует в сети прямо сейчас. Единственный авторитетный источник — это сам блокчейн. Проверяйте цепь.
Тестнет скрывает ошибки тайминга
Тестнеты имеют меньшую нагрузку, более быстрое включение и более предсказуемое поведение. Ошибки, чувствительные к тайингу, которые никогда не срабатывают на тестнете, будут появляться на мейннете. Если ваша логика зависит от тайминга между двумя событиями в сети, тестируйте ее в условиях реальной нагрузки.
Выходите с ошибкой громко
Когда цикл опроса истекает по времени, исправление выбрасывает ошибку и предотвращает выполнение транзакции. Это правильное поведение. Альтернатива — выполнить все равно и надеяться, что делегирование пришло — стоит 19 TRX за сбой. Сообщение об ошибке ничего не стоит.
Реализация MERX
Инструмент ensure_resources на сервере MERX MCP реализует этот паттерн. Когда AI агент вызывает ensure_resources перед выполнением вызова контракта, инструмент:
- Проверяет текущие ресурсы в сети
- Рассчитывает дефицит
- Покупает ровно необходимую энергию
- Опрашивает до подтверждения делегирования в сети
- Возвращает успех только когда ресурсы проверены
Агенту никогда не нужно реализовывать логику опроса самому. Состояние гонки обрабатывается на уровне платформы.
Tool: ensure_resources
Input: {
"address": "TYourAddress...",
"energy_needed": 65000
}
Response: {
"status": "confirmed",
"energy_available": 65000,
"confirmation_time_ms": 4200,
"order_id": "ord_abc123"
}
Поле confirmation_time_ms говорит агенту, сколько времени занял опрос. status: "confirmed" означает, что энергия в сети и безопасна для использования.
Платформа: https://merx.exchange
Документация: https://merx.exchange/docs
MCP сервер: https://github.com/Hovsteder/merx-mcp
Попробуйте прямо сейчас с AI
Добавьте MERX в Claude Desktop или любой совместимый с MCP клиент — ноль установки, нет API ключа, необходимого для инструментов только для чтения:
{
"mcpServers": {
"merx": {
"url": "https://merx.exchange/mcp/sse"
}
}
}
Попросите вашего AI агента: "Какая самая дешевая энергия TRON прямо сейчас?" и получите живые цены от всех подключенных провайдеров.
Полная документация MCP: merx.exchange/docs/tools/mcp-server