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 + 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 знайде хоч один спосіб, про який ти не думав, ці п'ять хвилин уже окупилися.