Free

Saat Claude Membongkar Rencana "23s ago" Buatan Saya Sendiri

Saya usulkan label "23s ago" untuk data on-chain. Claude menunjukkan itu kebohongan produk.


smarts.md adalah situs live docs untuk smart contract. Setiap halaman kontrak menampilkan sejumlah nilai yang dibaca langsung dari on-chain — liquidity pool Uniswap, totalSupply ERC-20, owner kontrak custom. Nilai-nilai itu bisa setua 60 detik (Solid Cache TTL = 60s).

Tapi di halaman sama sekali tidak ada indikator "ini dibaca kapan?". Tampilannya seperti Mintlify — tidak bisa dibedakan dari dokumen statis.

Saat audit strategi refresh data bareng Claude, saya daftar enam celah. Celah 4 cuma satu baris:

data live tanpa indikator kesegaran — tambahkan label "as of block X / Y seconds ago".

Selesai nulis to-do, saya pergi makan. Pulang, saya berhenti satu jam di baris itu. Arah akhir yang saya rilis benar-benar berbeda dari intuisi awal — dan Claude membantu saya sadar bahwa rencana awalnya sebenarnya kebohongan produk.


Intuisi pertama: tempel saja "23s ago"

Pikiran pertama saya begini:

liquidity: 1,234,567 USDC   ← 23s ago
totalSupply: 27.3B USDC     ← 12s ago

Sederhana, langsung, user paham seketika.

Sehabis makan saya lihat lagi dan mulai melempar pertanyaan ke Claude:

Si "23s" itu sebenarnya mengurangi apa dari apa?

Dia jelaskan: now - cache.fetched_at. Yaitu, waktu sejak nilai itu kita tulis ke cache.

Lalu seberapa jauh sebenarnya kita ketinggalan dari head chain?

Claude berpikir sebentar: Multicall-mu baca semua fungsi view sekaligus, tulis ke cache, cache valid 60s. Tapi block yang kita baca pada momen tulis itu sendiri sudah lewat — Ethereum punya batas bawah block time 0–12s.

Rantai kausalnya yang sebenarnya:

chain head menghasilkan block baru
  └→ RPC kita baca di block N (tertinggal 0–12s dari head)
       └→ tulis ke Solid Cache
            └→ render di layar user (cache hingga 60s)

Jadi label "23s ago" sebenarnya merepresentasikan segmen yang mana?

Hanya segmen terakhir: now - fetched_at, waktu dari penulisan cache sampai sekarang.

Keterlambatan di sisi chain sama sekali tidak tercermin.

Contoh konkret: user lihat "fetched 5s ago", padahal block-nya sendiri sudah berusia 12s, dan head chain mungkin sudah produksi 2 block lagi — keterlambatan chain riil ≈ 24s, label tertulis 5s.

Di situ saya sadar: meringkas "kesegaran" jadi satu angka tunggal secara fisik salah.

Tiga dimensi independen

Claude bantu saya menuliskannya dengan jelas — ada tiga dimensi independen:

Dimensi Yang diukur Batas bawah fisik
Kesegaran block head chain → block yang kita baca Ethereum block time 0–12s
Kesegaran baca RPC baca → cache tulis 0 (sinkron)
Kesegaran tampil cache tulis → layar user 0–60s (cache TTL)

Menjumlahkan ketiganya jadi satu "freshness: 35s" tidak ada secara fisik. Rantai chain → cache → screen tidak sinkron: cache membeku saat ditulis, sementara chain terus produksi block. Ini bukan masalah presisi, ini kesalahan kategori.

Yang lebih penting: segmen mana yang penting bergantung pada field-nya sendiri.

  • decimals: didefinisikan di constructor, tak pernah berubah. Sama sekali tak butuh kesegaran.
  • owner: hanya berubah lewat governance, bisa diam bertahun-tahun. Kamu perlu tahu di block mana itu dibaca (biar tidak melihat nilai "dari lima upgrade lalu"), tapi tidak perlu "23 detik lalu".
  • liquidity / slot0: bisa berubah tiap block. Block dan waktu, dua-duanya penting.

Solusinya: block + age sejajar, dipilah per tipe field

Block number adalah satu-satunya jangkar objektif — itu kebenaran on-chain, tidak terdistorsi oleh strategi cache kita.

Yang dirilis:

liquidity: 1,234,567 USDC
↳ as of Block #19,234,567 · 23s ago

Block untuk yang paham, "23s ago" untuk yang tidak. Keduanya tidak berbohong: block adalah fakta fisik, "23s ago" jelas berarti waktu sejak penulisan cache, bukan "ketertinggalan dari head chain".

Tampilan dibedakan per atribut field:

# 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

Aturan render:

Kelas Contoh Tampilan
immutable decimals, name, symbol tanpa kesegaran (akan jadi noise murni)
slow owner, paused hanya block, tanpa detik
fast slot0, liquidity, totalSupply block + detik

Whitelist ini sengaja konservatif — apapun di luar list dianggap fast. Lebih baik over-tag sebagai "live" daripada diam-diam menampilkan nilai mutable seolah statis.

Detail implementasi: block number nyaris gratis

Multicall3 sendiri punya fungsi getBlockNumber(). Tambahkan saja di akhir setiap batch — satu RPC, satu nilai tambahan, hampir tanpa overhead:

# 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 sekali jalan
  Batch.new(block_number: block_number, results: results)
end

Payload cache ViewCaller juga naik versi (CACHE_VERSION = "v2"), dari {fn_sig => result} jadi Snapshot(results:, block_number:, fetched_at:). Snapshot mendelegasikan def [](key) = @results[key] dkk untuk menjaga antarmuka hash lama tetap utuh — tidak ada caller yang perlu diubah.

Helper UI:

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

Total PR: 24 file, +676 / -55.

Kisah produk yang sebenarnya

Setelah rilis, saya menulis ulang diferensiator-nya:

Smarts adalah dokumentasi DeFi pertama yang memberitahumu, per field, di block mana state-nya dibaca.

Lebih konkret dan lebih kredibel daripada "kami lebih segar dari Mintlify". Ia mengekspos latensi nyata alih-alih menyembunyikannya — selaras dengan dua prinsip CLAUDE.md yang selalu saya kembalikan: "AI adalah alat, bukan etalase" + "Build in Public".

Pelajaran: minta Claude jadi pihak penentang

Setelah lebih dari setahun bekerja dengan Claude, kesimpulan terbesar saya bukan kecepatan dia menulis kode — melainkan dia akan serius berargumen melawan rencana saya sendiri, asalkan saya mau berhenti dan bertanya.

Saat saya usul "tambah label 23s ago", saya sendiri tidak melihat ada yang salah. Kalau saya yang review PR sendiri, kemungkinan besar saya merge — kelihatannya "jelas benar".

Tapi saat saya ganti nada dan tanya Claude "23s ini mengurangi apa dari apa, dan setara dengan segmen latensi yang mana?", dia jabarkan rantai kausalnya dan menunjukkan bahwa makna fisik angka itu salah.

Hasilnya bukan "Claude menulis lebih banyak kode untuk saya". Ini "Claude mencegah satu kebohongan produk masuk produksi".

Kalau 23s ago benar-benar dirilis, tak ada yang mengeluh — tampak masuk akal, dan orang yang akan menyadari bug-nya tidak akan repot menegur. Tapi di mata orang yang benar-benar paham, situs docs DeFi-mu punya lubang: situs ini tidak tahu apa yang sedang ditampilkannya.

Lain kali saat mengambil keputusan produk dengan Claude, sediakan 5 menit untuk langkah ini:

"Tentang rencana yang baru saya usulkan, sebutkan tiga cara ia bisa salah. Untuk masing-masing, beri skenario konkret."

Kalau Claude menemukan satu yang tidak pernah kamu pikirkan, lima menit itu sudah balik modal.