Free

Khi Claude lật tẩy phương án "23s ago" do chính tôi đề ra

Tôi đề xuất nhãn "23s ago" cho dữ liệu on-chain. Claude chỉ ra đó là một lời nói dối sản phẩm.


smarts.md là một site live docs cho smart contract. Mỗi trang contract hiển thị một loạt giá trị đọc trực tiếp từ on-chain — liquidity của Uniswap pool, totalSupply của ERC-20, owner của contract tự định nghĩa. Các giá trị này có thể cũ tới 60 giây (Solid Cache TTL = 60s).

Nhưng trang hoàn toàn không có dấu hiệu "đã đọc khi nào?". Nhìn như Mintlify — không khác gì một tài liệu tĩnh.

Khi cùng Claude rà soát chiến lược cập nhật dữ liệu, tôi liệt kê sáu khoảng trống. Khoảng trống số 4 chỉ một dòng:

dữ liệu live không có chỉ báo độ tươi — thêm nhãn "as of block X / Y seconds ago".

Viết xong todo, tôi đi ăn trưa. Quay lại tôi mắc kẹt một tiếng đồng hồ ở dòng đó. Hướng cuối cùng tôi đẩy lên production hoàn toàn khác với trực giác đầu tiên — và Claude giúp tôi nhận ra rằng phương án ban đầu thực ra là một lời nói dối sản phẩm.


Trực giác đầu tiên: cứ dán "23s ago" vào

Suy nghĩ đầu tiên của tôi như thế này:

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

Đơn giản, trực tiếp, người dùng hiểu ngay.

Sau bữa trưa nhìn lại, tôi bắt đầu hỏi Claude vài câu:

"23s" này thực ra trừ cái gì cho cái gì?

Nó trả lời rõ: now - cache.fetched_at. Tức là, thời gian từ lúc ta ghi giá trị vào cache.

Vậy thực sự ta tụt sau head của chain bao nhiêu?

Claude nghĩ một lát: Multicall của bạn đọc tất cả hàm view một lượt rồi ghi cache, cache hợp lệ trong 60s. Nhưng block ta đọc tại thời điểm ghi tự nó đã thuộc về quá khứ — Ethereum có sàn block time 0–12s.

Chuỗi nhân quả thực sự là:

head chain sinh block mới
  └→ RPC ta đọc tại block N (chậm 0–12s so với head)
       └→ ghi vào Solid Cache
            └→ render lên màn hình user (cache tối đa 60s)

Vậy "23s ago" trên nhãn tương ứng với đoạn nào?

Chỉ đoạn cuối: now - fetched_at, thời gian từ lúc ghi cache đến giờ.

Đoạn trễ phía chain hoàn toàn không được phản ánh.

Ví dụ cụ thể: user thấy "fetched 5s ago", nhưng block đó tự nó đã 12s tuổi, và head chain có thể đã sinh thêm 2 block nữa — độ trễ thực so với chain ≈ 24s, nhãn ghi 5s.

Đó là lúc tôi vỡ lẽ: gộp "độ tươi" thành một con số duy nhất là sai về mặt vật lý.

Ba chiều độc lập

Claude giúp tôi viết rõ ra — có ba chiều độc lập:

Chiều Đo cái gì Sàn vật lý
Độ tươi block head chain → block ta đọc Ethereum block time 0–12s
Độ tươi đọc RPC đọc → cache ghi 0 (đồng bộ)
Độ tươi hiển thị cache ghi → màn hình user 0–60s (cache TTL)

Cộng ba thứ này thành một "freshness: 35s" duy nhất không tồn tại về mặt vật lý. Chuỗi chain → cache → screen không đồng bộ: cache đóng băng ngay khi ghi xong, trong khi chain vẫn tiếp tục sinh block. Đây không phải vấn đề độ chính xác, mà là lỗi phạm trù.

Quan trọng hơn: đoạn nào quan trọng tùy thuộc vào chính field đó.

  • decimals: định nghĩa trong constructor, không bao giờ đổi. Hoàn toàn không cần độ tươi.
  • owner: chỉ đổi qua governance, có thể đứng yên hàng năm. Bạn cần biết đọc ở block nào (để chắc chắn không nhìn giá trị "năm lần upgrade trước"), nhưng không cần "23 giây trước".
  • liquidity / slot0: có thể đổi từng block. Cả block lẫn thời gian đều quan trọng.

Phương án sửa: block + age song song, phân loại theo loại field

Block number là neo khách quan duy nhất — đó là sự thật trên chain, không bị biến dạng bởi chiến lược cache của ta.

Phương án đẩy lên:

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

Block cho người am hiểu, "23s ago" cho người không am hiểu. Cả hai đều không nói dối: block là sự thật vật lý, "23s ago" rõ ràng nghĩa là thời gian từ lúc ghi cache, không phải "độ trễ so với head chain".

Hiển thị phân biệt theo thuộc tính 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

Quy tắc render:

Loại Ví dụ Hiển thị
immutable decimals, name, symbol không hiển thị độ tươi (nhiễu thuần túy)
slow owner, paused chỉ block, không có giây
fast slot0, liquidity, totalSupply block + giây

Whitelist này được thiết kế thận trọng — bất kỳ thứ gì không có trong list đều rơi vào fast. Thà đánh dấu thừa "có thể đổi" còn hơn lặng lẽ hiển thị giá trị mutable như tĩnh.

Chi tiết triển khai: block number gần như miễn phí

Bản thân Multicall3 có hàm getBlockNumber(). Chỉ cần thêm vào cuối mỗi batch — một RPC, thêm một giá trị, gần như không tăng độ trễ:

# 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 chạy một lần
  Batch.new(block_number: block_number, results: results)
end

Payload cache của ViewCaller cũng nâng version (CACHE_VERSION = "v2"), từ {fn_sig => result} thành Snapshot(results:, block_number:, fetched_at:). Snapshot ủy thác def [](key) = @results[key] cùng các method khác để giữ tương thích interface hash cũ — không caller nào phải đổi.

UI helper trông như sau:

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

Tổng PR: 24 file, +676 / -55.

Câu chuyện sản phẩm thực sự

Sau khi lên prod, tôi viết lại điểm khác biệt:

Smarts là tài liệu DeFi đầu tiên nói rõ với bạn, theo từng field, trạng thái của nó được đọc tại block nào.

Cụ thể hơn, đáng tin hơn câu "chúng tôi tươi hơn Mintlify". Nó phơi bày độ trễ thực thay vì giấu đi — nhất quán với hai nguyên tắc trong CLAUDE.md mà tôi luôn quay về: "AI là công cụ, không phải đồ trưng bày" + "Build in Public".

Bài học: cho Claude tranh luận chống lại bạn

Sau hơn một năm làm việc với Claude, kết luận lớn nhất của tôi không phải nó viết code nhanh — mà là nó sẽ tranh luận nghiêm túc chống lại chính phương án của tôi, miễn là tôi chịu dừng lại và hỏi.

Khi tôi đề xuất "thêm nhãn 23s ago", chính tôi không thấy gì sai. Nếu tự tôi review PR, tôi rất có thể đã merge — trông quá "hiển nhiên đúng".

Nhưng khi tôi đổi giọng và hỏi Claude "23s này trừ cái gì cho cái gì, và tương ứng với đoạn trễ nào?", nó trải ra chuỗi nhân quả và cho tôi thấy ý nghĩa vật lý của con số đó là sai.

Phần thưởng ở đây không phải "Claude viết thêm nhiều code". Mà là "Claude ngăn một lời nói dối sản phẩm khỏi production".

Nếu 23s ago thực sự được phát hành, không ai phàn nàn — nó trông hợp lý, và những người sẽ phát hiện bug cũng chẳng buồn nhắc. Nhưng dưới mắt người am hiểu thật sự, site docs DeFi của bạn có một lỗ hổng: site này không biết nó đang hiển thị cái gì.

Lần tới khi ra quyết định sản phẩm cùng Claude, dành 5 phút cho bước này:

"Với phương án tôi vừa đề xuất, hãy liệt kê ba cách nó có thể sai. Mỗi cách cho một kịch bản cụ thể."

Nếu Claude tìm ra một cách bạn chưa nghĩ tới, năm phút đó đã hoàn vốn.