Zaproponowałem etykietę "23s ago" dla danych on-chain. Claude pokazał, że to kłamstwo produktu.
smarts.md to strona live docs dla smart contractów. Każda strona kontraktu pokazuje zestaw wartości czytanych bezpośrednio z on-chain — liquidity puli Uniswap, totalSupply ERC-20, owner dowolnego kontraktu. Te wartości mogą mieć do 60 sekund opóźnienia (Solid Cache TTL = 60s).
Ale na stronie nie było żadnej informacji typu "kiedy to odczytano?". Wyglądało to jak Mintlify — nie do odróżnienia od statycznego dokumentu.
Audytując razem z Claude'em strategię odświeżania danych, wypisałem sześć luk. Luka 4 mieściła się w jednej linijce:
dane live bez wskaźnika świeżości — dodać label "as of block X / Y seconds ago".
Skończyłem todo i poszedłem na obiad. Wracając, utknąłem przy tej linijce na godzinę. Kierunek, który ostatecznie zmergowałem na produkcję, był zupełnie inny niż moja pierwsza intuicja — i Claude pomógł mi zrozumieć, że pierwotny plan był w zasadzie produktowym kłamstwem.
Pierwsza myśl była taka:
liquidity: 1,234,567 USDC ← 23s ago
totalSupply: 27.3B USDC ← 12s ago
Proste, bezpośrednie, użytkownik rozumie natychmiast.
Po obiedzie spojrzałem ponownie i zacząłem zadawać Claude'owi kilka pytań:
Czym to "23s" tak naprawdę odejmuje co od czego?
Wyłożył to jasno: now - cache.fetched_at. Czyli czas od momentu, kiedy zapisaliśmy wartość do cache.
A jak bardzo jesteśmy faktycznie do tyłu względem head'u łańcucha?
Claude pomyślał: twój Multicall czyta wszystkie funkcje view jednym strzałem, zapisuje do cache, cache jest ważny przez 60s. Ale block, w którym czytamy w momencie zapisu, sam już jest w przeszłości — Ethereum ma dolną granicę block time'u 0–12s.
Prawdziwy łańcuch przyczynowy:
head łańcucha produkuje nowy block
└→ nasz RPC czyta na block N (0–12s opóźnienia względem head'u)
└→ piszemy do Solid Cache
└→ render na ekranie użytkownika (cache do 60s)
Więc do jakiego segmentu odpowiada "23s ago" w labelu?
Tylko do ostatniego: now - fetched_at, czas od zapisu do cache do teraz.
Opóźnienie po stronie łańcucha nie jest w ogóle odzwierciedlone.
Konkretny przykład: użytkownik widzi "fetched 5s ago", ale ten block ma już 12s, a head łańcucha mógł wyprodukować jeszcze 2 bloki — realne opóźnienie względem łańcucha ≈ 24s, label mówi 5s.
Wtedy mi zaskoczyło: kompresowanie "świeżości" do jednej liczby jest fizycznie błędne.
Claude pomógł mi to spisać czarno na białym — są trzy niezależne wymiary:
| Wymiar | Co mierzy | Dolna granica fizyczna |
|---|---|---|
| Świeżość bloku | head łańcucha → block, który odczytaliśmy | block time Ethereum 0–12s |
| Świeżość odczytu | RPC odczyt → cache zapis | 0 (synchronicznie) |
| Świeżość wyświetlenia | cache zapis → ekran użytkownika | 0–60s (TTL cache) |
Sumowanie tych trzech do jednego "freshness: 35s" nie istnieje fizycznie. Łańcuch chain → cache → screen nie jest synchroniczny: cache zamarza w momencie zapisu, podczas gdy łańcuch dalej produkuje bloki. To nie problem precyzji, tylko błąd kategorii.
Co ważniejsze: który segment się liczy, zależy od samego pola.
decimals: zdefiniowane w konstruktorze, nigdy się nie zmienia. W ogóle nie potrzebuje świeżości.owner: zmienia się tylko przez governance, może stać latami. Trzeba wiedzieć, na jakim block to odczytano (żeby nie patrzeć na wartość "sprzed pięciu upgrade'ów"), ale nie "23 sekundy temu".liquidity / slot0: może zmieniać się co block. Block i czas — oba ważne.Numer bloku to jedyny obiektywny punkt zaczepienia — to prawda na łańcuchu, nieskażona naszą strategią cachowania.
Co poszło na produkcję:
liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago
Block dla tych, którzy wiedzą, "23s ago" dla pozostałych. Żadne z dwóch nie kłamie: block to fakt fizyczny, "23s ago" znaczy wprost czas od zapisu do cache, a nie "opóźnienie względem head'u łańcucha".
Zróżnicowane wyświetlanie po atrybucie pola:
# 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
Reguły renderingu:
| Klasa | Przykład | Wyświetlenie |
|---|---|---|
| immutable | decimals, name, symbol |
bez świeżości (byłby tylko szum) |
| slow | owner, paused |
tylko block, bez sekund |
| fast | slot0, liquidity, totalSupply |
block + sekundy |
Whitelista jest celowo zachowawcza — wszystko spoza listy trafia do fast. Lepiej oznaczyć coś nadgorliwie jako live, niż po cichu pokazywać zmienną wartość jako statyczną.
Sam Multicall3 udostępnia funkcję getBlockNumber(). Doklejamy ją na końcu każdego batcha — jeden RPC, jedna dodatkowa wartość, latencja niemal zero:
# 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 jednym strzałem
Batch.new(block_number: block_number, results: results)
end
Cache'owany payload ViewCaller też dostał bump wersji (CACHE_VERSION = "v2"), przechodząc z {fn_sig => result} na Snapshot(results:, block_number:, fetched_at:). Snapshot deleguje def [](key) = @results[key] i podobne, żeby zachować stary interfejs hash — żaden caller nie musiał się zmieniać.
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 razem: 24 pliki, +676 / -55.
Po wdrożeniu przepisałem differencjator:
Smarts to pierwsza dokumentacja DeFi, która mówi ci, dla każdego pola, na którym block jego stan został odczytany.
Bardziej konkretne i bardziej wiarygodne niż "jesteśmy świeżsi niż Mintlify". Odsłania rzeczywistą latencję zamiast ją chować — zgodnie z dwiema zasadami z mojego CLAUDE.md, do których wracam: "AI to narzędzie, nie wystawa" + "Build in Public".
Po ponad roku pracy z Claude'em mój największy wniosek to nie szybkość pisania kodu — tylko to, że on poważnie będzie sprzeczał się z moim własnym planem, jeśli zadam sobie trud i zatrzymam się, żeby zapytać.
Kiedy zaproponowałem "dodaj label 23s ago", sam nie widziałem niczego złego. Gdybym sam zrobił review swojego PR-a, prawdopodobnie zmergowałbym — wygląda zbyt "oczywiście słusznie".
Ale kiedy zmieniłem ton i zapytałem Claude "to 23s odejmuje co od czego, i jakiemu segmentowi latencji to odpowiada?", rozłożył łańcuch przyczynowy i pokazał mi, że fizyczne znaczenie liczby jest błędne.
Zysk tu nie polega na "Claude napisał mi więcej kodu". Polega na "Claude nie wpuścił produktowego kłamstwa na produkcję".
Gdyby 23s ago naprawdę wszedł, nikt by się nie poskarżył — wygląda rozsądnie, a ludzie, którzy zauważyliby buga, nie pofatygują się tego zgłaszać. Ale w oczach kogoś, kto naprawdę zna temat, twoja strona docs DeFi miałaby dziurę: ta strona nie wie, co pokazuje.
Następnym razem, podejmując decyzję produktową z Claude'em, poświęć 5 minut na ten krok:
"Do planu, który właśnie zaproponowałem, wymień trzy sposoby, w jakie może być błędny. Dla każdego daj konkretny scenariusz."
Jeśli Claude znajdzie jeden, o którym nie pomyślałeś, te 5 minut się już zwróciło.