Два платёжных рельса в одном ответе 402
Обычный сайт про аренду начал брать деньги с софта: один запрос 402 с USDC сразу на Base и на Solana, четыре гейта, которые держат второй рельс честным, и три бага, которые нашлись только за настоящие деньги.
Как обычный сайт про аренду начал брать деньги с софта, и что купили первые три цента
О какой системе речь
У меня сайт про Дананг, и отвечает он на два вопроса приезжего: где жить и кому позвонить. Аренда: около 8 250 объявлений вдолгую и ещё 480 посуточных, квартиры, дома и комнаты. Справочник услуг: 283 мастера и специалиста в десяти категориях, от аренды байков и помощи с визой до врачей и домашней еды. Плюс страница на каждый жилой комплекс и на каждый район. Искать можно обычной фразой на английском, вьетнамском или русском, а можно прислать скриншот карты, обведя нужный кусок города кружком.
Почти всё это я собираю из трёх мест: открытые посты в телеграм-группах, они же в группах фейсбука и Chợ Tốt, куда вьетнамцы выкладывают объявления. Остальное хозяева и мастера публикуют сами. Это доска объявлений, а не агентство: процента я не беру, хозяев не проверяю, депозитов не держу, и каждая карточка ведёт к тому, кто написал исходный пост.
Сайту три месяца, и за это время с его читателями произошли две разные вещи. Их стоит держать порознь, потому что к теме этой статьи ведёт только вторая.
Первая — сдвиг в человеческом трафике. Ассистент на базе ИИ приводит сейчас больше посетителей, чем органика гугла, а в июле такого источника не было вовсе. В конце этой цепочки по-прежнему человек: он спрашивает чат-бота, модель читает страницы, человек приходит уже подготовленным. Любопытно и к дальнейшему отношения не имеет, потому что эти читатели ничего не платят и никогда не должны были.
Вторая — арендаторы начали приходить со своим софтом. Двое знакомых иностранцев здесь построили себе агентов, которые ищут жильё за них: читают объявления без присмотра, сравнивают, к утру оставляют короткий список. Это читатель, который не открывает страницу и никогда не увидит формы регистрации, и именно он сделал необходимым всё остальное.
Поэтому у доски появилась поверхность для машин. Даже две: обычный REST API для всего, что говорит по HTTP, и MCP-сервер для агентов, которым хост подключает инструменты. Четыре инструмента, каждый только на чтение. Ничего на этом сервере не пишет, и ни в одном ответе нет ни телефона, ни мессенджера, так что агент, нашедший подходящее жильё, обязан отдать человеку ссылку на объявление.
Из чего и вырос вопрос, ради которого написана статья.
Платит софт, а софт не может зарегистрироваться
Отдавать эти данные стоит денег, а агент читает куда больше человека. Поэтому есть бесплатный суточный лимит, а за ним кто-то должен платить.
Стандартный ответ — аккаунт и привязанная карта — держится на одном условии: на человеке. Кто-то должен прочитать условия, решиться, ввести номер карты и согласиться с офертой.
А на той стороне человека нет. Если сервер отвечает «зарегистрируйтесь», агент либо встаёт и ждёт хозяина, либо уходит туда, где не спросили. И то и другое означает, что сделки не было.
402 и x402 — это разные вещи
Их стоит развести, потому что названия похожи и их постоянно путают.
402 — код состояния HTTP, Payment Required. Он в стандарте с 1996 года, зарезервирован и практически не использовался.
x402 — протокол, который придаёт ему смысл. Он определяет, что сервер кладёт
в заголовок PAYMENT-REQUIRED, какой формы описание платежа и как устроен API
третьей стороны, рассчитывающей деньги. У меня работает версия v2.
Обмен идёт в два захода и укладывается в три заголовка. За бесплатной чертой сервер отвечает
402 с заголовком PAYMENT-REQUIRED, где лежит описание в виде JSON в base64.
Клиент подписывает платёж и повторяет тот же самый запрос с заголовком
PAYMENT-SIGNATURE. Сервер передаёт пару фасилитатору, тот проверяет и вносит
транзакцию в цепь, после чего приходит 200 с заголовком PAYMENT-RESPONSE
и хешем транзакции.
Одно ограничение определяет всё остальное: приватного ключа на сервере нет. Внесение перевода в цепь стоит газа, значит нужен пополненный кошелёк, значит ключ лежал бы на том же сервере, который отдаёт объявления об аренде. Фасилитатор — единственная сторона в этой схеме, которой ключ нужен, и он не может изменить ни сумму, ни получателя: и то и другое находится внутри подписи, которую он ретранслирует. У приложения есть адрес получателя и больше ничего.
Почему один запрос несёт две цепи
Самое интересное в спецификации то, что accepts — это массив.
Массив существует затем, чтобы выбирал клиент. Альтернативы: второй эндпоинт, версия API или страница документации с объяснением, какой URL для какой цепи. Все три требуют сообщить каждому интегратору что-то вне протокола. Массив не требует ничего: универсальный x402-клиент читает запрос, находит запись в сети, где у него есть деньги, и платит, не прочитав ни строчки из написанного вами.
Поэтому запрос несёт две записи, USDC на Base и USDC на Solana, по одной цене, и клиент выбирает сам. Вот как это выглядит в живом ответе:
{
"x402Version": 2,
"accepts": [
{
"scheme": "exact",
"network": "eip155:8453",
"amount": "10000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "<адрес получателя>",
"maxTimeoutSeconds": 60,
"extra": { "name": "USD Coin", "version": "2" }
},
{
"scheme": "exact",
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"amount": "10000",
"asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"payTo": "<адрес получателя>",
"maxTimeoutSeconds": 60,
"extra": { "feePayer": "CjNFTjvBhbJJd2B5ePPMHRLx1ELZpa8dwQgGL727eKww" }
}
]
}
Схема одна, цена одна. 10000 атомарных единиц токена с шестью знаками после
запятой — это один цент. Сумма записана строкой намеренно: шестизначная дробь
в числе с плавающей точкой это ошибка округления, ждущая первого счёта,
кончающегося на пятёрку.
Один платёж покупает блок из пяти тысяч вызовов, а не один вызов. Расчёт на каждый запрос поставил бы запись в блокчейн между агентом и каждым прочитанным объявлением, а это надёжный способ сделать дешёвый API дорогим и медленным.
Где два рельса перестают быть похожими
Обе записи говорят scheme: "exact". После этого слова почти ничего не
совпадает.
На Base клиент подписывает разрешение EIP-3009 вне цепи. Это сообщение вида
«перевести столько-то этого токена от меня на такой-то адрес», подписанное
против домена EIP-712 самого контракта. Сервер ретранслирует подпись,
фасилитатор вызывает transferWithAuthorization у контракта.
На Solana ретранслировать нечего: объекта разрешения не существует. Клиент
собирает инструкцию TransferChecked, подписывает транзакцию частично и
отдаёт сериализованные байты. Фасилитатор добавляет себя как feePayer,
подписывает и отправляет.
Поэтому extra несёт в двух записях разное.
На Base это домен EIP-712, против которого проверяется подпись: name и
version контракта токена. Ошибётесь в любом из двух, и подпись восстановится
в другой адрес, а отказ будет выглядеть ошибкой клиента, а не вашей
конфигурации. Не переписывайте эти значения из документации, прочитайте их
у контракта:
name() у 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -> "USD Coin"
version() у того же контракта -> "2"
На Solana там лежит feePayer — публичный ключ, которым подпишется фасилитатор.
Клиент, который его не видит, не может собрать транзакцию вообще.
Две вещи про адреса, на которых теряют деньги
asset — это адрес, и на Solana это минт. Кто угодно может выпустить
SPL-токен и назвать его USDC, так что символ ничего не удостоверяет. Адрес минта —
единственное, что говорит, какой токен имеется в виду, и сервер, который где-то
в этой цепочке рассуждает про символы, имеет дыру.
payTo — не то место, куда приходят деньги. Перевод SPL по этой схеме идёт
на ассоциированный токен-счёт, выведенный из пары (кошелёк, минт), а не на
кошелёк, названный в payTo. Если этого счёта не существует, транзакция
плательщика либо падает, либо молча оплачивает ренту за его создание, и всё это
на платеже в один цент.
Второе — решение, а не сноска. Сервер проверяет существование принимающего токен-счёта, прежде чем вообще объявить рельс Solana, и отказывается вместо того, чтобы создать его. Создание — это запись в цепь, для неё нужен пополненный ключ, то есть ровно то, чего серверу держать нельзя. Владелец создаёт счёт один раз со своего кошелька, отправив на адрес любую сумму USDC.
Запись Solana либо полная, либо её нет
Четыре гейта решают, попадёт ли вторая запись в запрос:
- сеть включена настройкой;
- у этой сети назван свой фасилитатор;
- у принимающего кошелька уже есть токен-счёт под этот минт;
feePayerуспешно прочитан.
Не выполнено хотя бы одно, и запрос уходит с одной записью. Половинчатого
варианта нет: клиенту, который не видит feePayer, нечего подписывать, а запись
с дырой обойдётся агенту в один неудавшийся платёж.
feePayer читается у самого фасилитатора по адресу /supported в момент сборки
запроса, а не хранится в настройках. Ключ принадлежит им, они его меняют,
а протухшая копия в вашем окружении даёт транзакции, которые никто не отправит.
Чтение кэшируется на процесс, а неудачное чтение намеренно не кэшируется, чтобы
короткий сбой на их стороне не выключил рельс до следующего деплоя.
И ещё одно решение, которое стоит назвать прямо: один фасилитатор на рельс, а не на сервер. Base считается через Mogami, Solana через PayAI. Добавление второй сети не повод переводить работающий рельс к новому контрагенту, и два теста проверяют, что платёж на Base никогда не уходит фасилитатору Solana.
Три бага, и почему для них нужны были настоящие деньги
В наборе тестов почти три тысячи штук. Они были зелёными на всём протяжении того, что описано ниже.
Первый: лимит вычислений. Фасилитатор платит комиссию, поэтому ставит потолок на то, что готов спонсировать. Он принимает до пятидесяти тысяч единиц вычислений. Я просил двести тысяч. В спецификации написано, что потолок по умолчанию четыреста тысяч, оттуда моё число и взялось. В тексте ошибки называлась инструкция, а не значение, поэтому читалось это как «ваша инструкция негодна», хотя правда была «ваше число слишком велико».
Искал я его не с той стороны: начал со ста пятидесяти тысяч, вёл вверх до полутора миллионов и ни разу не спустился ниже ста тысяч, потому что решил, что число слишком мало. Проверка, которую я пропустил, стоила одного запроса к цепи: рассчитывает ли этот фасилитатор чужие платежи? Рассчитывал, штук пятнадцать в день, в том числе в те минуты, когда отказывал мне. Прежде чем объявлять чужой сервис сломанным, выясните, работает ли он у других.
Второй: ширина колонки. Расчёт прошёл, а вставка упала:
value too long for type character varying(80). Подписи Solana занимают
восемьдесят семь или восемьдесят восемь символов. Деньги оказались в цепи,
строки, которая их учитывает, не появилось, а плательщик получил пятисотку за
платёж, который состоялся. Починено миграцией.
Обратите внимание, что это пропустило. Тестовая база была на SQLite, который
длину varchar не проверяет. Прод на Postgres, который проверяет. Набор тестов,
идущий на другом движке, чем прод, структурно слеп к целому классу дефектов,
и никакое покрытие тут не помогает.
Третий: собственный таймаут, и он самый опасный. Сервер давал фасилитатору
пятнадцать секунд на расчёт. Mogami вносит транзакцию и ждёт подтверждения,
которое в четверть минуты не укладывается. Моя сторона клала трубку, отвечала
агенту «сервис недоступен», а перевод всё равно доходил. Дважды, одинаково.
Деньги взяли, ничего не выдали, и единственным следом остались две строки
в journalctl, которые нельзя ни запросить, ни сверить с цепью.
Отсюда две правки. У расчёта появился свой дедлайн, отдельный от проверки, такой,
чтобы оба уместились в окно до убийства веб-воркера. И строка теперь заводится
до броска и закрывается исходом после. Обрыв связи оставляет её в состоянии
unknown, что и есть честное описание: деньги, вероятно, ушли, подтверждения
нет. Эта строка видна в админке, её можно найти запросом, и ежечасная задача
сопоставляет её с неопознанным входящим переводом и довыдаёт квоту без человека.
Общая цена выяснения всех трёх: три цента, взятых и не выданных.
Что я сказал бы тому, кто это строит
Читайте спецификацию, а не статьи о ней. Формат провода уже один раз менялся:
в v1 требования лежали в теле ответа обычным JSON, а заголовки несли префикс
X-. В v2 их перенесли в base64-заголовок, а префикс исчез. Клиент, написанный
под v1, запроса v2 не увидит вовсе, и отказ будет молчаливым с обеих сторон.
Кладите описание платежа в ответ, а не в документацию. Смысл протокола в том, что клиент, ни разу не открывавший ваш сайт, может вам заплатить.
Тестируйте на том же движке базы, что стоит на проде, и один раз потратьте настоящие деньги. Всем трём дефектам выше нужна была цепь, чтобы проявиться, и ни одному не нужна была крупная сумма: по центу за попытку хватило на все.
Решите, где живёт ключ, прежде чем решать что-либо ещё. Всё остальное в этой конструкции следует из того, что у сервера есть адрес и больше ничего.
Эндпоинт работает. За суточным бесплатным лимитом обе поверхности отвечают 402 с описанным выше запросом, на двух цепях, а расчёт идёт через два названных выше фасилитатора. Описание и запрос, который можно снять самому, лежат на helprentdanang.com/for-agents.
Весь рельс занял выходные на сборку и несколько центов на проверку, и дальше работает без присмотра. Ради этого соотношения о нём и стоит знать. Не потому, что машинные платежи сегодня велики, а потому что возможность их принимать перестала быть проектом и стала делом одного вечера.
Нужны такие системы или клиенты к ним?