Предложил метку "23s ago" для on-chain-данных. Claude показал, что это продуктовая ложь.
smarts.md — это сайт live docs для смарт-контрактов. На каждой странице контракта есть набор значений, прочитанных напрямую с блокчейна — liquidity пула Uniswap, totalSupply ERC-20, owner произвольного контракта. Эти значения могут устаревать максимум на 60 секунд (Solid Cache TTL = 60s).
Но на странице не было никакого указания "когда это прочитано?". Выглядело как Mintlify — не отличить от статического документа.
Аудируя стратегию обновления данных вместе с Claude, я выписал шесть пробелов. Пробел 4 уместился в одну строку:
у живых данных нет индикатора свежести — добавить лейбл "as of block X / Y seconds ago".
Я записал todo и пошёл обедать. Вернувшись, застрял на этой строке на час. Направление, которое я в итоге смержил в прод, оказалось совсем не тем, к которому подталкивала первая интуиция — и Claude помог мне понять, что исходный план был, по сути, продуктовой ложью.
Первая мысль была такой:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Просто, прямо, пользователь понимает мгновенно.
После обеда я снова посмотрел и начал задавать Claude несколько вопросов:
Что именно этот "23s" вычитает из чего?
Он расписал по слогам: now - cache.fetched_at. То есть время с момента, как мы записали значение в кеш.
Тогда насколько мы реально отстаём от головы цепи?
Claude подумал: твой Multicall разом читает все view-функции, пишет в кеш, кеш живёт 60s. Но сам block, на котором мы читаем в момент записи, уже находится в прошлом — у Ethereum нижний предел блока 0–12s.
Реальная причинно-следственная цепочка:
голова цепи производит новый block
└→ наш RPC читает на block N (отставание 0–12s от головы)
└→ пишем в Solid Cache
└→ рендер на экране пользователя (кеш до 60s)
И к какому отрезку относится "23s ago"?
Только к последнему: now - fetched_at, время от записи в кеш до сейчас.
Отставание со стороны цепи вообще не отражено.
Конкретный пример: пользователь видит "fetched 5s ago", но самому блоку уже 12s, и голова цепи могла произвести ещё 2 блока — реальное отставание от цепи ≈ 24s, а лейбл говорит 5s.
Тут и щёлкнуло: схлопнуть "свежесть" в одно число — физически неверно.
Claude помог мне записать это чётко — есть три независимых измерения:
| Измерение | Что измеряет | Физический предел |
|---|---|---|
| Свежесть блока | голова цепи → прочитанный нами block | block time Ethereum 0–12s |
| Свежесть чтения | RPC прочитал → cache записал | 0 (синхронно) |
| Свежесть отображения | cache записал → экран пользователя | 0–60s (TTL кеша) |
Сложить эти три в единое "freshness: 35s" физически невозможно. Цепь chain → cache → screen не синхронна: кеш замораживается в момент записи, а блокчейн продолжает производить блоки. Это не проблема точности, а ошибка категории.
Важнее: какой отрезок важен — зависит от самого поля.
decimals: задано в конструкторе, никогда не меняется. Свежесть вообще не нужна.owner: меняется только через governance, может стоять годами. Нужно знать, на каком block это прочитано (чтобы не смотреть значение "пятиапгрейдной давности"), но не "23 секунды назад".liquidity / slot0: может меняться каждый block. Важны и block, и время.Номер блока — единственный объективный якорь — это правда на блокчейне, не искажённая нашей стратегией кеширования.
Что в проде:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block — для понимающих, "23s ago" — для остальных. Ни одно из двух не врёт: block — физический факт, "23s ago" явно означает время с момента записи в кеш, а не "отставание от головы цепи".
Дифференцированное отображение по атрибутам поля:
# app/services/chain_reader/field_mutability.rb
module ChainReader
module FieldMutability
IMMUTABLE = %w[
name symbol decimals
DOMAIN_SEPARATOR PERMIT_TYPEHASH
factory token0 token1 fee tickSpacing
asset underlying
].freeze
SLOW = %w[
owner
paused deprecated
pauser blacklister masterMinter rescuer
upgradedAddress implementation
maxTotalSupply
].freeze
def self.classify(function_name)
return :immutable if IMMUTABLE.include?(function_name)
return :slow if SLOW.include?(function_name)
:fast
end
end
end
Правила рендера:
| Класс | Пример | Отображение |
|---|---|---|
| immutable | decimals, name, symbol |
без свежести (был бы чистый шум) |
| slow | owner, paused |
только block, без секунд |
| fast | slot0, liquidity, totalSupply |
block + секунды |
Белый список нарочно консервативный — всё, чего нет в списке, попадает в fast. Лучше переотметить значение как "живое", чем молча показать изменчивое значение как статичное.
В Multicall3 уже есть функция getBlockNumber(). Добавляем её в конец каждого batch — один RPC, одно дополнительное значение, латентность практически нулевая:
# app/services/chain_reader/multicall3_client.rb
BLOCK_NUMBER_FN = {
"name" => "getBlockNumber",
"inputs" => [],
"outputs" => [{ "type" => "uint256" }]
}.freeze
def call(calls)
block_call = Call.new(target: ADDRESS, function: BLOCK_NUMBER_FN)
augmented = calls + [block_call]
# ...aggregate3 одним заходом
Batch.new(block_number: block_number, results: results)
end
Кешированный payload ViewCaller тоже получил версионный bump (CACHE_VERSION = "v2"), перейдя с {fn_sig => result} на Snapshot(results:, block_number:, fetched_at:). Snapshot делегирует def [](key) = @results[key] и подобные методы, чтобы сохранить старый hash-интерфейс — ни одного caller'а не пришлось менять.
UI-helper:
def freshness_tag_for(fn)
return nil unless @live_snapshot&.block_number
mutability = ChainReader::FieldMutability.classify(fn["name"])
return nil if mutability == :immutable
block_text = "Block ##{number_with_delimiter(@live_snapshot.block_number)}"
label =
if mutability == :fast && @live_snapshot.fetched_at
"as of #{block_text} · #{freshness_phrase(@live_snapshot.fetched_at)}"
else
"as of #{block_text}"
end
# ...
end
PR суммарно: 24 файла, +676 / -55.
После релиза я переписал дифференциатор:
Smarts — первая документация по DeFi, которая по каждому полю явно сообщает, на каком block прочитано его состояние.
Это конкретнее и достовернее, чем "мы свежее, чем Mintlify". Это обнажает реальную задержку вместо того, чтобы её прятать — соответствует двум принципам из моего CLAUDE.md, к которым я постоянно возвращаюсь: "AI — инструмент, а не витрина" + "Build in Public".
После более чем года работы с Claude мой главный вывод не в скорости написания кода — а в том, что он будет всерьёз спорить с моим же планом, если я возьму на себя труд остановиться и спросить.
Когда я предложил "добавить лейбл 23s ago", сам ничего плохого не видел. Если бы сам ревьюил свой PR, скорее всего смержил бы — выглядит слишком "очевидно правильно".
Но когда я сменил тон и спросил Claude "что этот 23s вычитает из чего, и какому отрезку задержки соответствует?", он развернул причинную цепочку и показал, что физический смысл числа неверный.
Выигрыш здесь не "Claude написал мне больше кода". Это "Claude не пустил продуктовую ложь в продакшн".
Если бы 23s ago действительно вышел, никто бы не пожаловался — выглядит резонно, а те, кто заметил бы баг, не стали бы тратить силы на сообщение. Но в глазах того, кто действительно знает тематику, на твоём DeFi-docs-сайте была бы дыра: этот сайт не знает, что показывает.
В следующий раз, принимая продуктовое решение с Claude, выдели 5 минут на этот шаг:
"По плану, который я только что предложил, перечисли три способа, которыми он может оказаться неверным. Для каждого приведи конкретный сценарий."
Если Claude найдёт хотя бы один, о котором ты не подумал, эти пять минут уже окупились.