diff --git a/prisma/migrations/20260901140000_add_token_metadata/migration.sql b/prisma/migrations/20260901140000_add_token_metadata/migration.sql new file mode 100644 index 00000000..637ad21c --- /dev/null +++ b/prisma/migrations/20260901140000_add_token_metadata/migration.sql @@ -0,0 +1,22 @@ +-- Cache token symbol / name / decimals so the indexer does not re-query the +-- same contract's metadata on every transfer it sees. +-- +-- Keyed on (network, contractId), not contractId alone: a contract id is only +-- unique within a chain, so a testnet token deployed at the same address as a +-- mainnet one would otherwise share a row — serving the wrong symbol and, more +-- damagingly, the wrong `decimals`, which silently rescales every amount +-- rendered from it. +-- +-- Ordered after 20260829120000_add_network, which back-filled `network` onto +-- the tables that existed when it was written; this was not one of them. +CREATE TABLE "wraith"."TokenMetadata" ( + "network" TEXT NOT NULL DEFAULT 'testnet', + "contractId" TEXT NOT NULL, + "symbol" TEXT NOT NULL, + "name" TEXT NOT NULL, + "decimals" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TokenMetadata_pkey" PRIMARY KEY ("network", "contractId") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7aebfd16..c7d65ca9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -339,3 +339,22 @@ model BackfillCursor { @@schema("wraith") } + +// ─── Token Metadata ─────────────────────────────────────────────────────────── +// Caches token symbol, name, and decimals to avoid redundant RPC calls. +model TokenMetadata { + // A contract id is only unique within a chain. Keyed on contractId alone, a + // testnet token at the same address as a mainnet one would serve the wrong + // symbol and the wrong decimals — and a wrong decimals silently rescales + // every amount rendered from it by orders of magnitude. + network String @default("testnet") + contractId String + symbol String + name String + decimals Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([network, contractId]) + @@schema("wraith") +} diff --git a/src/__tests__/tokenCache.test.ts b/src/__tests__/tokenCache.test.ts new file mode 100644 index 00000000..aaf85bdb --- /dev/null +++ b/src/__tests__/tokenCache.test.ts @@ -0,0 +1,146 @@ +import { + getTokenMetadata, + initTokenCache, + getAllCachedTokens, + _resetTokenCache, +} from "../tokenCache"; +import { prisma } from "../db"; +import { fetchTokenMetadata } from "../rpc"; + +jest.mock("../db", () => ({ + prisma: { + tokenMetadata: { + findMany: jest.fn(), + findUnique: jest.fn(), + upsert: jest.fn(), + }, + }, +})); + +jest.mock("../rpc", () => ({ + fetchTokenMetadata: jest.fn(), +})); + +describe("Token Cache", () => { + const mockToken = { + network: "testnet" as const, + contractId: "C123", + symbol: "TKN", + name: "Token", + decimals: 7, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // The cache is module-level state and survives between tests. Clearing it + // keeps each case independent — otherwise a "hit" in one test can be + // satisfied by a value some earlier test happened to leave behind. + _resetTokenCache(); + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([]); + (prisma.tokenMetadata.findUnique as jest.Mock).mockResolvedValue(null); + }); + + it("populates cache from DB on init", async () => { + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]); + + await initTokenCache("testnet"); + + expect(prisma.tokenMetadata.findMany).toHaveBeenCalledWith({ + where: { network: "testnet" }, + }); + expect(getAllCachedTokens()).toContainEqual(mockToken); + }); + + it("returns cached metadata without an RPC call", async () => { + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]); + await initTokenCache("testnet"); + + const result = await getTokenMetadata("C123", "testnet"); + + expect(result).toEqual(mockToken); + expect(fetchTokenMetadata).not.toHaveBeenCalled(); + }); + + it("fetches from RPC and persists to DB on cache miss", async () => { + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + symbol: "NEW", + name: "New Token", + decimals: 9, + }); + + const result = await getTokenMetadata("C456", "testnet"); + + expect(result.symbol).toBe("NEW"); + expect(fetchTokenMetadata).toHaveBeenCalledWith("C456", "testnet"); + expect(prisma.tokenMetadata.upsert).toHaveBeenCalledWith({ + where: { network_contractId: { network: "testnet", contractId: "C456" } }, + create: expect.objectContaining({ symbol: "NEW", network: "testnet" }), + update: expect.objectContaining({ symbol: "NEW", network: "testnet" }), + }); + }); + + it("does not serve one network's token for the same id on another", async () => { + // A contract id is only unique within a chain. Sharing a cache entry across + // networks would serve the wrong symbol and, far worse, the wrong + // `decimals` — silently rescaling every amount rendered from that token. + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]); + await initTokenCache("testnet"); + + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + symbol: "MAIN", + name: "Mainnet Token", + decimals: 2, + }); + + const result = await getTokenMetadata("C123", "mainnet"); + + expect(result.symbol).toBe("MAIN"); + expect(result.decimals).toBe(2); + expect(fetchTokenMetadata).toHaveBeenCalledWith("C123", "mainnet"); + }); + + it("asks the RPC for the same network it caches the answer under", async () => { + // Querying the wrong chain returns nothing or a different token, and that + // answer would then be cached under the network that was asked for. + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + symbol: "X", + name: "X", + decimals: 7, + }); + + await getTokenMetadata("CXYZ", "mainnet"); + + expect(fetchTokenMetadata).toHaveBeenCalledWith("CXYZ", "mainnet"); + expect(getAllCachedTokens("mainnet")).toHaveLength(1); + expect(getAllCachedTokens("testnet")).toHaveLength(0); + }); + + it("narrows getAllCachedTokens to one network when asked", async () => { + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]); + await initTokenCache("testnet"); + + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + symbol: "M", + name: "M", + decimals: 7, + }); + await getTokenMetadata("CMAIN", "mainnet"); + + expect(getAllCachedTokens()).toHaveLength(2); + expect(getAllCachedTokens("testnet").map((t) => t.contractId)).toEqual(["C123"]); + expect(getAllCachedTokens("mainnet").map((t) => t.contractId)).toEqual(["CMAIN"]); + }); + + it("keeps serving from RPC when the DB seed fails", async () => { + // A cold cache is recoverable; refusing to start is not. + (prisma.tokenMetadata.findMany as jest.Mock).mockRejectedValue(new Error("db down")); + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + symbol: "OK", + name: "OK", + decimals: 7, + }); + + await expect(initTokenCache("testnet")).resolves.toBeUndefined(); + await expect(getTokenMetadata("CANY", "testnet")).resolves.toMatchObject({ symbol: "OK" }); + }); +}); diff --git a/src/api.ts b/src/api.ts index 1deaaeee..ce23390b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,6 +26,7 @@ import { import { parseOr400 } from "./openapi/validation"; import { networkMiddleware, requestNetwork } from "./middleware/network"; import { renderMetrics, metricsContentType } from "./metrics"; +import { getAllCachedTokens } from "./tokenCache"; // ─── RPC Health Check Cache ─────────────────────────────────────────────── // Keyed by network (#163): one cache entry would let a healthy testnet RPC @@ -254,6 +255,19 @@ export function createApp(): express.Application { next(err); } }); + + // ── GET /tokens ───────────────────────────────────────────────────────────── + /** + * Returns a list of all tokens encountered and cached by the indexer. + */ + app.get("/tokens", (req: Request, res: Response) => { + // Scoped to the selected network. Returning both chains' tokens from one + // endpoint would put two different assets under the same contract id in a + // single list, with no way for a caller to tell which is which. + const network = requestNetwork(req); + const tokens = getAllCachedTokens(network); + res.json({ ok: true, network, tokens }); + }); // ─── GET /readyz — K8s/Render readiness probe ─────────────────────────── /** diff --git a/src/indexer.ts b/src/indexer.ts index 102b9685..fbfcb075 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -87,6 +87,7 @@ export function resolveSacContractIds(network?: Network): string[] { // both loops at the same chain's SAC. return [net === "mainnet" ? DEFAULT_XLM_SAC_MAINNET : DEFAULT_XLM_SAC_TESTNET]; } +import { initTokenCache, getTokenMetadata } from "./tokenCache"; // ─── Config ─────────────────────────────────────────────────────────────────── // These stay process-wide: they describe how hard to poll, not which chain. @@ -321,6 +322,17 @@ async function pollOnce( console.error(`[indexer/${net}] SAC detection failed:`, e) ); const inserted = await upsertTransfers(records, net); + + // Resolve metadata for every distinct token in this batch. Only a cache miss + // reaches RPC, and a miss happens once per contract for the life of the + // database — so this is one extra call the first time a token is seen and + // free thereafter. Best-effort: a token whose metadata cannot be read is + // still worth indexing transfers for. + await Promise.all( + [...new Set(records.map((r) => r.contractId))].map((contractId) => + getTokenMetadata(contractId, net).catch(() => undefined) + ) + ); loop.totalIndexed += inserted; transfersStoredTotal.inc({ network: net, type: "fungible" }, inserted); @@ -414,6 +426,10 @@ export async function startIndexer(network?: Network): Promise { const loop = createLoopState(net); loops.set(net, loop); + // Warm the token metadata cache from the database so the first batch does + // not pay an RPC round-trip per contract it has already seen before. + await initTokenCache(net); + // Seed the known-pool set from what is already recorded. Without this a // restart forgets which contracts are pools and silently stops recording // their bare mint/burn until the next explicit deposit comes through. diff --git a/src/rpc.ts b/src/rpc.ts index aec981f0..9063904f 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -1,4 +1,4 @@ -import { rpc as RPC, xdr } from "@stellar/stellar-sdk"; +import { rpc as RPC, xdr, scValToNative, Contract, TransactionBuilder, Account, Networks } from "@stellar/stellar-sdk"; import { resolveNetwork, currentNetwork, type Network } from "./network"; import { recordRpcError } from "./metrics"; @@ -234,3 +234,58 @@ export async function fetchEventsSafe( }; } } + +// ─── Token Metadata ────────────────────────────────────────────────────────── +/** + * Fetch token metadata (symbol, decimals, name) from a Soroban token contract. + * Uses simulateTransaction to call the read-only getter methods. + */ +export async function fetchTokenMetadata( + contractId: string, + network?: Network, +): Promise<{ + symbol: string; + decimals: number; + name: string; +}> { + // Both the RPC endpoint and the passphrase must come from the network being + // asked about, not from STELLAR_NETWORK. With a loop per network (#161), + // reading the process default here would simulate a mainnet contract call + // against testnet — returning either nothing or a different token entirely. + const net = resolveNetwork(network); + const rpc = getRpc(net); + const contract = new Contract(contractId); + const networkPassphrase = net === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; + + // Helper to call a zero-arg method and decode the result + const callMethod = async (method: string): Promise => { + // Build a dummy transaction for simulation. Source account and sequence + // don't matter for read-only simulation. + const tx = new TransactionBuilder( + new Account("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", "0"), + { fee: "100", networkPassphrase } + ) + .addOperation(contract.call(method)) + .setTimeout(0) + .build(); + + const resp = await rpc.simulateTransaction(tx); + if (RPC.Api.isSimulationSuccess(resp)) { + const scVal = resp.result!.retval; + return scValToNative(scVal); + } + throw new Error(`RPC simulation failed for ${method}: ${JSON.stringify(resp)}`); + }; + + const [symbol, decimals, name] = await Promise.all([ + callMethod("symbol"), + callMethod("decimals"), + callMethod("name"), + ]); + + return { + symbol: String(symbol), + decimals: Number(decimals), + name: String(name), + }; +} diff --git a/src/tokenCache.ts b/src/tokenCache.ts new file mode 100644 index 00000000..ca73583d --- /dev/null +++ b/src/tokenCache.ts @@ -0,0 +1,103 @@ +import { prisma } from "./db"; +import { fetchTokenMetadata } from "./rpc"; +import { resolveNetwork, type Network } from "./network"; + +export interface TokenMetadata { + network: Network; + contractId: string; + symbol: string; + name: string; + decimals: number; +} + +/** + * In-memory cache, keyed by `network:contractId`. + * + * The network has to be part of the key. A contract id is only unique within a + * chain, so a testnet token deployed at the same address as a mainnet one would + * otherwise serve the wrong symbol and — worse — the wrong `decimals`, which + * silently rescales every amount rendered from it. + */ +const cache = new Map(); + +function cacheKey(network: Network, contractId: string): string { + return `${network}:${contractId}`; +} + +/** + * Populate the in-memory cache from the database on startup. + * + * Loads only the given network's rows: a loop indexing one chain has no use for + * the other's tokens, and keeping them apart is the point of the composite key. + */ +export async function initTokenCache(network?: Network): Promise { + const net = resolveNetwork(network); + try { + const tokens = await prisma.tokenMetadata.findMany({ where: { network: net } }); + for (const token of tokens) { + cache.set(cacheKey(net, token.contractId), token as TokenMetadata); + } + console.log(`[cache/${net}] Initialized with ${tokens.length} tokens from DB`); + } catch (err) { + console.error( + `[cache/${net}] Failed to initialize token cache from DB:`, + (err as Error).message, + ); + // Continue anyway; it will fill from RPC as needed. + } +} + +/** + * Get token metadata for a contract on a network. + * Checks memory → then the database → then Soroban RPC. + */ +export async function getTokenMetadata( + contractId: string, + network?: Network, +): Promise { + const net = resolveNetwork(network); + const key = cacheKey(net, contractId); + + // 1. In-memory cache. + const cached = cache.get(key); + if (cached) return cached; + + // 2. Database — another process, or an earlier run, may already have it. + const dbToken = await prisma.tokenMetadata.findUnique({ + where: { network_contractId: { network: net, contractId } }, + }); + if (dbToken) { + cache.set(key, dbToken as TokenMetadata); + return dbToken as TokenMetadata; + } + + // 3. Soroban RPC. Asked of this network's endpoint rather than the process + // default: querying the wrong chain returns either nothing or another token. + console.log(`[cache/${net}] Cache miss for ${contractId} — fetching from RPC…`); + const metadata = await fetchTokenMetadata(contractId, net); + const token: TokenMetadata = { network: net, contractId, ...metadata }; + + // 4. Persist to the database and memory. + await prisma.tokenMetadata.upsert({ + where: { network_contractId: { network: net, contractId } }, + create: token, + update: token, + }); + + cache.set(key, token); + return token; +} + +/** + * Return all tokens currently held in the in-memory cache, optionally narrowed + * to one network. + */ +export function getAllCachedTokens(network?: Network): TokenMetadata[] { + const all = Array.from(cache.values()); + return network ? all.filter((t) => t.network === network) : all; +} + +/** Test-only: drop the in-memory cache. */ +export function _resetTokenCache(): void { + cache.clear(); +}