Реализация протокола x402: Счёт, Платёж, Верификация
Код состояния HTTP 402 -- "Payment Required" -- был определён в исходной спецификации HTTP/1.1 в 1997 году. Спецификация отметила его как "зарезервировано для будущего использования". Двадцать девять лет спустя будущее наступило.
Протокол x402 превращает этот зарезервированный код состояния в реальный механизм платежей. Он обеспечивает оплату по мере использования, где платёж проверяется в блокчейне, а не через учётные записи, ключи API или кредитные карты. Любой объект с кошельком блокчейна -- человек, бот или автономный агент -- может оплатить услугу в одной транзакции без создания учётной записи или предварительного взаимодействия с поставщиком услуги.
MERX реализует x402 для покупки энергии TRON. Эта статья — полное техническое руководство по реализации: создание счёта, платёж с меморандумом, верификация TronGrid (включая проблему сопоставления адресов hex и base58), системный пользователь x402, зачисление баланса и выполнение заказа. Мы охватываем каждый шаг, каждый граничный случай и каждое соображение безопасности.
Общий обзор потока x402
Протокол состоит из пяти шагов:
1. INVOICE - Покупатель запрашивает квоту, сервер возвращает инструкции платежа
2. PAY - Покупатель отправляет TRX с меморандумом, содержащим ID счёта
3. VERIFY - Сервер обнаруживает платёж в блокчейне и валидирует его
4. CREDIT - Сервер зачисляет счёт системы x402 и создаёт записи в реестр
5. EXECUTE - Сервер выполняет заказ энергии и делегирует её покупателю
Каждый шаг разработан как trustless. Покупатель никогда не отправляет средства на непроверенный адрес. Сервер никогда не выполняет заказ без подтверждённого платежа. Поле меморандума связывает платёж с конкретным счётом, предотвращая атаки перекрёстных платежей.
Шаг 1: Создание счёта
Покупатель вызывает endpoint create_paid_order с желаемыми параметрами энергии:
POST /api/v1/orders/paid
Content-Type: application/json
{
"energy_amount": 65000,
"duration_hours": 1,
"target_address": "TBuyerAddress..."
}
Сервер рассчитывает стоимость на основе текущих лучших цен всех провайдеров и возвращает счёт:
{
"invoice": {
"order_id": "xpay_a7f3c2d1",
"amount_trx": 1.43,
"amount_sun": 1430000,
"pay_to": "TMerxTreasuryAddress...",
"memo": "merx_xpay_a7f3c2d1",
"expires_at": "2026-03-30T12:05:00Z",
"energy_amount": 65000,
"duration_hours": 1,
"target_address": "TBuyerAddress..."
}
}
Решения проектирования счёта
Срок действия 5 минут. Счёт истекает через 5 минут после создания. Это окно достаточно длительно, чтобы покупатель мог просмотреть, подписать и трансляировать платёж. Оно достаточно короткое, чтобы предотвратить использование устаревших цен -- если цены на энергию существенно изменятся, покупатель должен запросить новый счёт по текущей цене.
Точная требуемая сумма. Платёж должен точно совпадать с суммой счёта. Не больше, не меньше. Это предотвращает неоднозначность при сопоставлении платежей со счётами. Если покупатель отправляет 2 TRX за счёт на 1.43 TRX, платёж отклоняется (избыток создал бы сложность учёта без выгоды).
Уникальный меморандум. Поле меморандума содержит уникальный идентификатор, который связывает платёж с этим конкретным счётом. Это критический механизм безопасности -- подробнее ниже.
Хранилище счётов на сервере
INSERT INTO x402_invoices (
order_id,
amount_sun,
pay_to,
memo,
expires_at,
energy_amount,
duration_hours,
target_address,
status
) VALUES (
'xpay_a7f3c2d1',
1430000,
'TMerxTreasuryAddress...',
'merx_xpay_a7f3c2d1',
'2026-03-30T12:05:00Z',
65000,
1,
'TBuyerAddress...',
'PENDING'
);
Счёт сохраняется со статусом PENDING. Он перейдёт в статус PAID при верификации или EXPIRED, когда TTL пройдёт без платежа.
Шаг 2: Платёж с меморандумом
Покупатель конструирует и подписывает транзакцию передачи TRX. Критическая деталь реализации -- поле меморандума.
const tronWeb = new TronWeb({
fullHost: 'https://api.trongrid.io'
});
// Построить базовую транзакцию
const tx = await tronWeb.transactionBuilder.sendTrx(
invoice.pay_to, // TMerxTreasuryAddress
invoice.amount_sun, // 1430000
buyerAddress // TBuyerAddress
);
// Добавить меморандум
const txWithMemo = await tronWeb.transactionBuilder.addUpdateData(
tx,
invoice.memo, // "merx_xpay_a7f3c2d1"
'utf8'
);
// Подписать локально
const signedTx = await tronWeb.trx.sign(txWithMemo, privateKey);
// Трансляировать
const result = await tronWeb.trx.sendRawTransaction(signedTx);
console.log('TX hash:', result.txid);
Почему меморандум, а не сумма
Более ранний дизайн рассматривал использование уникальных сумм (например, 1,430,017 SUN вместо 1,430,000 SUN) для идентификации платежей. Этот подход хрупок:
- Коллизии сумм возможны (два счёта могут иметь одинаковую цену)
- Это требует от покупателя платить нестандартную сумму
- Это не работает, когда несколько счётов имеют идентичные параметры
Поле меморандума обеспечивает однозначный идентификатор без риска коллизий.
Безопасность приватного ключа
Приватный ключ покупателя никогда не покидает его устройство. Транзакция конструируется, подписывается и трансляируется полностью на машине покупателя. MERX никогда не видит, не запрашивает и не имеет доступа к приватному ключу покупателя. Это фундаментальное свойство безопасности протокола x402.
Шаг 3: Верификация TronGrid
После трансляции платежа MERX должна её верифицировать в блокчейне. Здесь реализация становится интересной -- и возникает значительная техническая проблема.
Цикл мониторинга
Монитор депозитов MERX постоянно отслеживает входящие транзакции на адресе казны:
async function monitorTreasuryForX402Payments(): Promise<void> {
const treasuryAddress = process.env.TREASURY_ADDRESS;
while (true) {
const transactions = await tronWeb.trx.getTransactionsRelated(
treasuryAddress,
'to',
{ limit: 50, only_confirmed: true }
);
for (const tx of transactions) {
await processIncomingTransaction(tx);
}
await sleep(3000); // Проверять каждые 3 секунды (один блок)
}
}
Проблема сопоставления адресов hex и base58
Вот техническая проблема, которая потребовала больше времени отладки, чем любая другая часть реализации x402.
Адреса TRON существуют в двух форматах:
- Base58:
TJRabPrwbZy45sbavfcjinPJC18kjpRTv8(читаемый для человека, начинается с T) - Hex:
415a523b449890854c8fc460ab602df9f31fe4293f(hex с префиксом 41, используется внутри)
Когда вы запрашиваете детали транзакции у TronGrid, ответ использует адреса в формате hex. Когда ваш счёт сохраняет адрес покупателя и адрес казны, они в формате base58. Если вы сравниваете их напрямую, они никогда не совпадут.
// Транзакция из API TronGrid
const txData = {
owner_address: '415a523b449890854c8fc460ab602df9f31fe4293f', // hex
to_address: '41e552f6487585c2b58bc2c9bb4492bc1f17132cd0', // hex
amount: 1430000
};
// Счёт из базы данных
const invoice = {
pay_to: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', // base58
target_address: 'TBuyerAddressBase58...', // base58
amount_sun: 1430000
};
// Прямое сравнение НЕ РАБОТАЕТ
txData.to_address === invoice.pay_to // false (hex vs base58)
Исправление: Преобразование перед сравнением
Каждое сравнение адресов должно преобразовать обе стороны в один формат:
function normalizeAddress(address: string): string {
if (address.startsWith('41') && address.length === 42) {
// Формат hex -- преобразовать в base58
return tronWeb.address.fromHex(address);
}
if (address.startsWith('T') && address.length === 34) {
// Уже base58
return address;
}
throw new Error(`Invalid TRON address format: ${address}`);
}
function addressesMatch(a: string, b: string): boolean {
return normalizeAddress(a) === normalizeAddress(b);
}
Эта функция нормализации используется в каждом сравнении адресов во всём конвейере верификации x402. Пропуск даже одной точки сравнения создал бы уязвимость.
Полная логика верификации
async function verifyX402Payment(tx: TronTransaction): Promise<void> {
// 1. Извлечь меморандум из данных транзакции
const memo = extractMemo(tx);
if (!memo || !memo.startsWith('merx_xpay_')) {
return; // Не платёж x402, пропустить
}
// 2. Найти совпадающий счёт
const invoice = await findInvoiceByMemo(memo);
if (!invoice) {
console.warn(`No invoice found for memo: ${memo}`);
return;
}
// 3. Проверить статус счёта
if (invoice.status !== 'PENDING') {
console.warn(`Invoice ${invoice.order_id} already ${invoice.status}`);
return; // Предотвращает двойное требование
}
// 4. Проверить срок действия
if (new Date() > new Date(invoice.expires_at)) {
await markInvoiceExpired(invoice.order_id);
console.warn(`Invoice ${invoice.order_id} expired`);
return;
}
// 5. Верифицировать сумму (требуется точное совпадение)
if (tx.amount !== invoice.amount_sun) {
console.warn(
`Amount mismatch: TX=${tx.amount}, invoice=${invoice.amount_sun}`
);
return;
}
// 6. Верифицировать получателя (безопасное сравнение hex vs base58)
if (!addressesMatch(tx.to_address, invoice.pay_to)) {
console.warn('Recipient address mismatch');
return;
}
// Все проверки пройдены -- платёж валиден
await processValidPayment(invoice, tx);
}
Извлечение меморандума
Меморандум сохраняется в поле raw_data.data транзакции как hex-кодированная строка:
function extractMemo(tx: TronTransaction): string | null {
try {
const hexData = tx.raw_data?.data;
if (!hexData) return null;
// Декодировать hex в UTF-8
const memo = Buffer.from(hexData, 'hex').toString('utf8');
return memo;
} catch {
return null;
}
}
Шаг 4: Системный пользователь x402
Когда платёж x402 верифицирован, MERX должна зачислить платёж и выполнить заказ. Но платежи x402 -- это платежи без учётных записей -- у покупателя нет учётной записи MERX. Как создавать записи в реестр без учётной записи?
Решение -- системный пользователь x402. Это специальная внутренняя учётная запись, которая представляет все транзакции x402:
INSERT INTO accounts (id, email, type)
VALUES (
'x402-system-00000000-0000-0000-0000-000000000000',
'x402@system.merx.exchange',
'SYSTEM'
);
Зачисление баланса
Когда платёж x402 верифицирован, система:
- Зачисляет счёт системного пользователя x402 (баланс увеличивается)
- Снимает со счёта казны (полученный TRX)
- Незамедлительно снимает со счёта системного пользователя x402 (платёж заказа)
- Зачисляет счёт расчёта провайдера (платёж провайдеру)
BEGIN;
-- Зачислить счёт системного пользователя x402 (платёж получен)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($x402_system_id, 'X402_PAYMENT', 1430000, 'CREDIT', $order_id);
-- Снять со счёта казны (TRX получен в блокчейне)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($treasury_id, 'X402_PAYMENT', 1430000, 'DEBIT', $order_id);
-- Снять со счёта системного пользователя x402 (платёж заказа)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($x402_system_id, 'ORDER_PAYMENT', 1430000, 'DEBIT', $order_id);
-- Зачислить счёт расчёта провайдера (MERX должна провайдеру)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($provider_settlement_id, 'ORDER_PAYMENT', 1430000, 'CREDIT', $order_id);
COMMIT;
Баланс счёта системного пользователя x402 всегда должен быть нулевым или близким к нулю: каждое зачисление (полученный платёж) немедленно компенсируется снятием (выполненный заказ). Если баланс растёт, это означает, что платежи получаются, но заказы не выполняются -- это сигнал тревоги.
Шаг 5: Выполнение заказа
После зачисления платежа заказ выполняется через стандартный конвейер заказов MERX:
async function processValidPayment(
invoice: X402Invoice,
tx: TronTransaction
): Promise<void> {
// Отметить счёт как PAID
await updateInvoiceStatus(invoice.order_id, 'PAID', tx.txid);
// Создать записи в реестр (как показано выше)
await createX402LedgerEntries(invoice, tx);
// Выполнить заказ энергии
const order = await executeOrder({
energy_amount: invoice.energy_amount,
duration_hours: invoice.duration_hours,
target_address: invoice.target_address,
source: 'x402',
reference_tx: tx.txid
});
// Обновить счёт с результатом заказа
await updateInvoiceWithOrder(invoice.order_id, order);
}
Выполнение заказа следует по тому же пути, что и любой другой заказ MERX: маршрутизация с лучшей ценой, выбор провайдера, делегирование и верификация в блокчейне (включая исправление race condition из нашей предыдущей статьи).
Безопасность: Верификация меморандума предотвращает перекрёстные платежи
Поле меморандума -- это стержень безопасности x402. Без него существует критический вектор атаки.
Атака перекрёстного платежа
Представьте x402 без меморандумов. Два пользователя одновременно запрашивают счёта:
Alice запрашивает 65,000 энергии для TAliceAddress. Счёт: 1.43 TRX на TMerxTreasury.
Bob запрашивает 65,000 энергии для TBobAddress. Счёт: 1.43 TRX на TMerxTreasury.
Оба счёта имеют одинаковую сумму и один и тот же адрес pay_to. Если Bob платит за свой счёт, как MERX узнает, делегировать ли энергию на TAliceAddress или TBobAddress? Без меморандума платёж неоднозначен.
Ещё хуже: Bob мог бы заплатить один раз и потребовать оба счёта. Или Alice мог бы потребовать платёж Bob'а для своего собственного счёта.
Как меморандумы это предотвращают
Счёт Alice: memo = "merx_xpay_alice123"
Счёт Bob: memo = "merx_xpay_bob456"
Платёж Alice TX: 1.43 TRX на TMerxTreasury, memo = "merx_xpay_alice123"
Платёж Bob TX: 1.43 TRX на TMerxTreasury, memo = "merx_xpay_bob456"
Верификация:
Меморандум платежа Alice совпадает со счётом Alice -> делегировать на TAliceAddress
Меморандум платежа Bob совпадает со счётом Bob -> делегировать на TBobAddress
Каждый платёж однозначно связан со своим счётом. Нет способа потребовать перекрёстно.
Дополнительные проверки безопасности
Помимо сопоставления меморандумов, конвейер верификации включает:
Предотвращение двойного платежа. Когда счёт отмечен как PAID, последующие платежи с тем же меморандумом отклоняются. Плательщику пришлось бы обратиться в поддержку для возврата (или система автоматически вернула бы средства, если сумма превышает счёт).
Точность суммы. Платёж должен точно совпадать с суммой счёта. Это предотвращает частичные платежи (которые требовали бы сложной логики частичного выполнения) и переплату (которая требовала бы логики возврата).
Проверка срока действия. Платежи, полученные после истечения срока действия счёта, не обрабатываются. Это предотвращает использование устаревших цен, где покупатель запрашивает счёт в период низких цен, ждёт роста цен и затем платит за старый счёт.
Верификация адреса. Платёж должен поступить на правильный адрес казны. Если пользователь каким-то образом платит на другой адрес (ошибка копирования-вставки, фишинг), платёж не будет обнаружен монитором.
Обработка ошибок
Платёж без счёта
Если передача TRX поступит на адрес казны с меморандумом, который не совпадает ни с одним счётом (опечатка, истёкший счёт, тестовая транзакция), платёж логируется, но не обрабатывается. Средства остаются в казне. В рабочей системе это вызвало бы сигнал тревоги поддержки для ручного рассмотрения и потенциального возврата.
Сбой провайдера после платежа
Если провайдер энергии не удаётся делегировать после верифицированного платежа:
try {
const order = await executeOrder(invoice);
} catch (error) {
// Заказ не выполнен -- вернуть счёт системного пользователя x402
await createRefundLedgerEntries(invoice);
// Отметить счёт как REFUND_REQUIRED
await updateInvoiceStatus(invoice.order_id, 'REFUND_REQUIRED');
// Оповестить команду ops для ручного возврата TRX на адрес плательщика
await alertOps({
type: 'X402_REFUND_REQUIRED',
invoice: invoice.order_id,
payer_address: extractSenderFromTx(tx),
amount_sun: invoice.amount_sun
});
}
Возврат создаёт новые записи в реестр (никогда не модифицирует существующие) и флагирует счёт для ручной обработки возврата.
Сетевая перегруженность
Во время высокой нагрузки на сеть промежуток между трансляцией платежа и его подтверждением может выходить за границы 5-минутного окна счёта. Система обрабатывает это, проверяя временную метку транзакции (когда она была трансляирована) вместо временной метки подтверждения (когда она была включена в блок). Если транзакция была трансляирована до истечения срока действия счёта, она принимается, даже если подтверждение приходит после истечения.
Резюме
Реализация x402 в MERX демонстрирует, что trustless платежи без учётных записей практичны уже сегодня. Ключевые решения проектирования:
- Счёт с уникальным меморандумом -- однозначное связывание платёжа с заказом
- Точное сопоставление сумм -- устраняет сложность частичных и чрезмерных платежей
- Срок действия 5 минут -- предотвращает использование устаревших цен
- Нормализация hex в base58 -- решает проблему формата адреса TronGrid
- Системный пользователь x402 -- обеспечивает двойную бухгалтерию без учётных записей покупателей
- Неизменяемые записи реестра -- полный аудит для каждой транзакции x402
Протокол превращает HTTP 402 из 29-летнего placeholder'а в работающий механизм платежа. Для AI агентов, которые не могут создавать учётные записи или управлять ключами API, x402 делает энергию TRON доступной через одну транзакцию в блокчейне.
Платформа: 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