Skip to content

Materialize on-chain token metadata via instruction decoding #48

Description

@nabinpkl

Context

Today /primitive/get_token_info does a fresh getAccountInfo call to mainnet RPC on every allowed request (single-flighted per mint). The previous design (b533e9e removed) cached resolved metadata in multichain.token_metadata but had no invalidation signal: an on-chain UpdateMetadataAccountV2 would silently make our cache wrong forever.

That left us with a forced choice:

  • Live-fetch every read: fresh, simple, but every read costs an RPC ticket and we cannot maintain derived views (symbol → mint, asset-side aggregates).
  • Cache without invalidation: fast and view-friendly, but unbounded staleness.

A third path exists and is what this issue covers: cache + invalidation via instruction decoding. Decode metadata-mutating instructions in the ingester so the cache stays fresh modulo ingester lag (~1.5s, slot-level).

Architecture

Hook the existing ingest pipeline (backend/src/ingest/parser.rs) to walk every instruction and inner instruction in every successful tx, match program ids against the metadata programs, and decode the data buffer when matched. Emit a MetadataUpdate { mint, name, symbol, uri, update_authority, slot } event onto a new Kafka topic. A new stream sink consumer materializes multichain.token_metadata (ReplacingMergeTree by slot so the latest update wins).

Decoders

Program Address Instructions to decode
Metaplex Token Metadata metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s CreateMetadataAccountV3, UpdateMetadataAccountV2
SPL Token-2022 metadata extension TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb TokenMetadataInitialize, TokenMetadataUpdateField, TokenMetadataRemoveKey, TokenMetadataEmit

Both programs use borsh-encoded instruction data with stable layouts. Metaplex's IDL is published; Token-2022's metadata extension is in the SPL source. Hand-rolled borsh structs are the right move here (the codebase already does this for Metaplex account decode in backend/src/metadata/fetch.rs).

Inner instructions

Metadata writes via CPI from another program show up as inner instructions. Need to walk transaction.meta.inner_instructions[], not just the top-level instructions.

Components

  1. backend/src/ingest/metadata_decode.rs (new): pure decode functions, one per (program, instruction). Take a CompiledInstruction + account list, return Option<MetadataUpdate>.
  2. backend/src/ingest/parser.rs extension: alongside the existing edge/memo parse, walk instructions for metadata events.
  3. backend/src/stream/metadata_stream.rs (new): IngestStream<MetadataUpdate> impl. Reuses the existing IngestStream<T> abstraction extracted in 8e9053d.
  4. backend/src/store/clickhouse_metadata_store.rs (new): EdgeStore-shaped sink for MetadataUpdate, upserts into multichain.token_metadata.
  5. multichain.token_metadata schema (restore from b533e9e + add slot column for ReplacingMergeTree ordering):
    CREATE TABLE multichain.token_metadata (
        mint String,
        name String,
        symbol String,
        uri String,
        update_authority String,
        source_program LowCardinality(String),
        fetched_at_slot UInt64,
        updated_at DateTime DEFAULT now()
    ) ENGINE = ReplacingMergeTree(fetched_at_slot)
    ORDER BY mint;
  6. get_token_info handler reads from cache. Live RPC becomes the fallback for never-seen mints (the lazy-backfill path landing in the bridge issue).

Symbol → mint resolution

Once the materialized view is reliable, a new primitive find_mint(symbol) -> [{mint, name, symbol, uri, ...}, ...] is a CH WHERE lower(symbol) = ? query with multi-row results (impostor mints become visible in the data, not hidden). Asset-side primitives like token_activity(mint, window_secs) and window_active_mints() build on the same table.

This is a follow-up, not part of this issue's deliverable, but the schema and stream shape are designed with it in mind.

Edge cases

  • Initial backfill for old mints. USDC was minted in 2020. Tip-only ingestion will never see its CreateMetadataAccountV3. The bridge issue (lazy backfill + write-through cache with TTL refresh) covers this: first observation of a mint hits RPC and writes the row. CDC keeps it fresh thereafter.
  • Token-2022 metadata storage shape. Metadata lives in the mint account itself via the metadata extension, not a separate PDA. Mutations are still discrete instructions (TokenMetadataInitialize, TokenMetadataUpdateField, etc.), so decoding works the same way; just need to verify the full mutation instruction set against current SPL Token-2022 source before declaring coverage.
  • Successful tx filter. Only successful txs mutate state. Filter on meta.err.is_none() before decoding.
  • Re-org tolerance. We process finalized slots only (existing ingester behavior); fork choice has resolved by then.
  • Other metadata programs. Metaplex Core (newer NFT standard) and MPL Bubblegum (compressed NFTs) have their own programs and instruction surfaces. Out of scope for this issue; decoders can be added incrementally as needed.

Out of scope

  • Off-chain JSON metadata refresh (the URI's content): different staleness shape, no on-chain instruction signal. Tracked separately under Off-chain token metadata fetch as extension of get_token_info #46.
  • NFT-specific metadata (collection, creators, royalties): the current OnChainMetadata struct surfaces only fungible-relevant fields. NFT shape is an additive change later.
  • Cross-chain metadata: Solana-only.

Bridge work

While this lands, a small predecessor change restores the cache table + lazy backfill + TTL refresh. That gives get_token_info cache-hit performance immediately, with a 1-hour TTL bounding staleness during the gap. Once this issue ships, the TTL refresh path becomes dead code (CDC keeps the cache fresh via instruction-driven writes) and gets removed in the same change as the decoder rolls out.

Done when

  • Metaplex CreateMetadataAccountV3 + UpdateMetadataAccountV2 decoded into MetadataUpdate events at ingest time, written to multichain.token_metadata.
  • Token-2022 TokenMetadataInitialize + TokenMetadataUpdateField decoded the same way.
  • get_token_info reads cache first, RPC fallback only on never-seen mints.
  • Live verification: rotate a Token-2022 mint's symbol on devnet, observe the CH row update within 2 slots.
  • TTL refresh path removed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions