Запропонував мітку "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, і час.Номер блоку — єдиний об'єктивний якір — це правда на блокчейні, не спотворена нашою стратегією кешування.
Що в проді:
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 знайде хоч один спосіб, про який ти не думав, ці п'ять хвилин уже окупилися.