Free

Как Claude разбил мой собственный план "23s ago"

Предложил метку "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 помог мне понять, что исходный план был, по сути, продуктовой ложью.


Первая интуиция: просто прилепить "23s ago"

Первая мысль была такой:

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, и время.

Решение: block + age параллельно, разделено по типу поля

Номер блока — единственный объективный якорь — это правда на блокчейне, не искажённая нашей стратегией кеширования.

Что в проде:

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 спорит против тебя

После более чем года работы с Claude мой главный вывод не в скорости написания кода — а в том, что он будет всерьёз спорить с моим же планом, если я возьму на себя труд остановиться и спросить.

Когда я предложил "добавить лейбл 23s ago", сам ничего плохого не видел. Если бы сам ревьюил свой PR, скорее всего смержил бы — выглядит слишком "очевидно правильно".

Но когда я сменил тон и спросил Claude "что этот 23s вычитает из чего, и какому отрезку задержки соответствует?", он развернул причинную цепочку и показал, что физический смысл числа неверный.

Выигрыш здесь не "Claude написал мне больше кода". Это "Claude не пустил продуктовую ложь в продакшн".

Если бы 23s ago действительно вышел, никто бы не пожаловался — выглядит резонно, а те, кто заметил бы баг, не стали бы тратить силы на сообщение. Но в глазах того, кто действительно знает тематику, на твоём DeFi-docs-сайте была бы дыра: этот сайт не знает, что показывает.

В следующий раз, принимая продуктовое решение с Claude, выдели 5 минут на этот шаг:

"По плану, который я только что предложил, перечисли три способа, которыми он может оказаться неверным. Для каждого приведи конкретный сценарий."

Если Claude найдёт хотя бы один, о котором ты не подумал, эти пять минут уже окупились.