Repository navigation
bug: Request(Dictionary) отправляет api_version под именем ApiVersion, узел его игнорирует и отвечает по v1 #97
Description
Что
IXrplClient.Request(Dictionary<string, object>) кладёт версию API под ключом nameof(ApiVersion), то есть буквально "ApiVersion":
if(!request.TryGetValue(nameof(ApiVersion), out var value)){
{
request[nameof(ApiVersion)] = ApiVersion;
}}Ключ уходит на провод как есть — словарь сериализуется без политики именования. rippled такого поля не знает, молча его игнорирует и обрабатывает запрос по версии по умолчанию. Настроенный на клиенте ApiVersion до узла не доходит никогда.
Доказательство
Один клиент, одна команда, одна и та же запрошенная версия. Дискриминатор — версия, которую узел заведомо не умеет: путь, который реально кладёт api_version на провод, обязан получить отказ.
подключились на api_version 2, затем client.ApiVersion = 99 (такой версии нет)
типизированный ServerInfo() : ОТКАЗ -> invalid_API_version - Requested API version is higher than maximum supported (3)
нетипизированный Request(dictionary) : УСПЕХ -> версия не доставлена (ключи ответа: info, validated)
И то же на уровне провода, сырым WebSocket к wss://s2.ripple.com, подписка transactions:
без поля версии вообще -> transaction (v1), верхнеуровневый hash: нет
"api_version":2 -> tx_json (v2), верхнеуровневый hash: да
"ApiVersion":2 -> transaction (v1), верхнеуровневый hash: нет
Написание ApiVersion неотличимо от полного отсутствия поля. Типизированный путь при этом корректен: BaseRequest.ApiVersion помечен [JsonPropertyName("api_version")], а GRequest проставляет request.ApiVersion ??= ApiVersion с умолчанием 2.
Чем это плохо
Внутри одного клиента два пути дают разные версии протокола: client.AccountInfo(...) идёт по v2, а client.Request(new Dictionary { ["command"] = "account_info" }) — по v1, потому что узел не увидел версии и взял свою по умолчанию. Формы ответов между версиями отличаются, так что потребитель, смешивающий пути, получает несогласованные данные и никакого сигнала об этом.
Плюс на проводе в каждом таком запросе едет мусорное поле "ApiVersion".
Исправление и его риск
Само исправление тривиально — писать в словарь "api_version" и по нему же проверять, не задал ли вызывающий версию сам. Заодно стоит прибрать двойные скобки и неиспользуемый out var value.
Но правка меняет наблюдаемое поведение, и это надо решить осознанно. Сейчас все пользователи Request(Dictionary) фактически работают по v1. После починки они автоматически переедут на значение ApiVersion, а это по умолчанию 2 — то есть у них поменяются формы ответов на уже работающем коде, без единой строчки изменений с их стороны. Варианты, как это подать:
- починить и описать в
CHANGES.mdкак breaking с явным указанием, что затронут только нетипизированный путь; - починить, но для этого пути оставить умолчание 1, чтобы поведение не поменялось, и дать возможность задать версию явно;
- не менять ключ, а бросать исключение, если в словаре встречается
"ApiVersion"— то есть превратить тихую потерю в громкую ошибку.
Мне ближе первый вариант: сейчас поведение всё равно не то, которое заявлено, а расхождение между двумя путями одного клиента — худшая из возможных неожиданностей.
Как нашлось
Всплыло при проверке #96: там от версии API зависит, в каком свойстве лежит хеш транзакции, и по ходу выяснилось, что «SDK по умолчанию работает на v2» верно только для типизированного пути.
Activity
Platonenkov commentedon Aug 17, 2026
Исправлено в #99, коммит
73e83f7наdev.Выбран первый из трёх вариантов, что обсуждались в задаче: ключ меняется на
api_version, словарный путь начинает уважать настроенныйApiVersion, слом контракта описан вCHANGES.mdявно — вместе с тем, как остаться на v1 (["api_version"] = 1в словаре илиApiVersionна клиенте).Тесты читают то, что клиент реально кладёт на провод, через сервер-перехватчик запросов: поле, которое узел игнорирует, из ответа не видно — именно поэтому дефект не ловился ни одним существующим тестом. Написаны до правки и падали все три.
Главный риск правки — что словарный путь в тестах и демо поедет по v2 — проверен отдельно: интеграционные на standalone-ноде 265 / 265, и повторно в очереди мержа на результате слияния. Ни одна команда в v2 не ответила иначе, чем ожидают модели.