You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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
backend/src/ingest/metadata_decode.rs (new): pure decode functions, one per (program, instruction). Take a CompiledInstruction + account list, return Option<MetadataUpdate>.
backend/src/ingest/parser.rs extension: alongside the existing edge/memo parse, walk instructions for metadata events.
backend/src/stream/metadata_stream.rs (new): IngestStream<MetadataUpdate> impl. Reuses the existing IngestStream<T> abstraction extracted in 8e9053d.
backend/src/store/clickhouse_metadata_store.rs (new): EdgeStore-shaped sink for MetadataUpdate, upserts into multichain.token_metadata.
multichain.token_metadata schema (restore from b533e9e + add slot column for ReplacingMergeTree ordering):
CREATETABLEmultichain.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;
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.
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.
Context
Today
/primitive/get_token_infodoes a freshgetAccountInfocall to mainnet RPC on every allowed request (single-flighted per mint). The previous design (b533e9e removed) cached resolved metadata inmultichain.token_metadatabut had no invalidation signal: an on-chainUpdateMetadataAccountV2would silently make our cache wrong forever.That left us with a forced choice:
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 aMetadataUpdate { mint, name, symbol, uri, update_authority, slot }event onto a new Kafka topic. A new stream sink consumer materializesmultichain.token_metadata(ReplacingMergeTree byslotso the latest update wins).Decoders
metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1sCreateMetadataAccountV3,UpdateMetadataAccountV2TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEbTokenMetadataInitialize,TokenMetadataUpdateField,TokenMetadataRemoveKey,TokenMetadataEmitBoth 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
backend/src/ingest/metadata_decode.rs(new): pure decode functions, one per (program, instruction). Take aCompiledInstruction+ account list, returnOption<MetadataUpdate>.backend/src/ingest/parser.rsextension: alongside the existing edge/memo parse, walk instructions for metadata events.backend/src/stream/metadata_stream.rs(new):IngestStream<MetadataUpdate>impl. Reuses the existingIngestStream<T>abstraction extracted in 8e9053d.backend/src/store/clickhouse_metadata_store.rs(new):EdgeStore-shaped sink forMetadataUpdate, upserts intomultichain.token_metadata.multichain.token_metadataschema (restore from b533e9e + addslotcolumn for ReplacingMergeTree ordering):get_token_infohandler 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 CHWHERE lower(symbol) = ?query with multi-row results (impostor mints become visible in the data, not hidden). Asset-side primitives liketoken_activity(mint, window_secs)andwindow_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
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.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.meta.err.is_none()before decoding.Out of scope
OnChainMetadatastruct surfaces only fungible-relevant fields. NFT shape is an additive change later.Bridge work
While this lands, a small predecessor change restores the cache table + lazy backfill + TTL refresh. That gives
get_token_infocache-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
CreateMetadataAccountV3+UpdateMetadataAccountV2decoded intoMetadataUpdateevents at ingest time, written tomultichain.token_metadata.TokenMetadataInitialize+TokenMetadataUpdateFielddecoded the same way.get_token_inforeads cache first, RPC fallback only on never-seen mints.