Free

Jak Claude obalił mój własny plan "23s ago"

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 intuicja: po prostu przykleić "23s ago"

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.

Trzy niezależne wymiary

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.

Naprawa: block + age obok siebie, posegmentowane po typie pola

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ą.

Implementacja: numer bloku jest praktycznie darmowy

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.

Prawdziwa historia produktowa

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".

Lekcja: niech Claude argumentuje przeciwko tobie

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.