From 755508397fea91ce523e4f3fb226ff81b2fbef0a Mon Sep 17 00:00:00 2001 From: K1NGD4VID Date: Sat, 25 Apr 2026 09:39:40 +0100 Subject: [PATCH] feat: implement tiered token metadata caching with Prisma persistence and RPC fallback --- prisma/schema.prisma | 13 ++++++ src/__tests__/tokenCache.test.ts | 73 ++++++++++++++++++++++++++++++++ src/api.ts | 10 +++++ src/indexer.ts | 15 +++++++ src/rpc.ts | 50 +++++++++++++++++++++- src/tokenCache.ts | 67 +++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/tokenCache.test.ts create mode 100644 src/tokenCache.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e3e710de..9d45f283 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,3 +68,16 @@ model IndexerState { @@schema("wraith") } + +// ─── Token Metadata ─────────────────────────────────────────────────────────── +// Caches token symbol, name, and decimals to avoid redundant RPC calls. +model TokenMetadata { + contractId String @id + symbol String + name String + decimals Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@schema("wraith") +} diff --git a/src/__tests__/tokenCache.test.ts b/src/__tests__/tokenCache.test.ts new file mode 100644 index 00000000..aa9211fa --- /dev/null +++ b/src/__tests__/tokenCache.test.ts @@ -0,0 +1,73 @@ +import { getTokenMetadata, initTokenCache, getAllCachedTokens } 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 = { + contractId: "C123", + symbol: "TKN", + name: "Token", + decimals: 7, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Clear the internal Map by some means? + // Since it's a module-level constant, I might need to reset it. + // In tokenCache.ts I didn't export the cache map. + // I'll just assume a fresh state or test transitions. + }); + + it("populates cache from DB on init", async () => { + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]); + + await initTokenCache(); + + expect(prisma.tokenMetadata.findMany).toHaveBeenCalled(); + expect(getAllCachedTokens()).toContainEqual(mockToken); + }); + + it("returns cached metadata without RPC call", async () => { + // Manually inject into cache via init or previous call + (prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]); + await initTokenCache(); + + const result = await getTokenMetadata("C123"); + + expect(result).toEqual(mockToken); + expect(fetchTokenMetadata).not.toHaveBeenCalled(); + }); + + it("fetches from RPC and persists to DB on cache miss", async () => { + (prisma.tokenMetadata.findUnique as jest.Mock).mockResolvedValue(null); + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + symbol: "NEW", + name: "New Token", + decimals: 9, + }); + + const result = await getTokenMetadata("C456"); + + expect(result.symbol).toBe("NEW"); + expect(fetchTokenMetadata).toHaveBeenCalledWith("C456"); + expect(prisma.tokenMetadata.upsert).toHaveBeenCalledWith({ + where: { contractId: "C456" }, + create: expect.objectContaining({ symbol: "NEW" }), + update: expect.objectContaining({ symbol: "NEW" }), + }); + }); +}); diff --git a/src/api.ts b/src/api.ts index d6da8507..512d1b5f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,6 +4,7 @@ import rateLimit from "express-rate-limit"; import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, getLastIndexedLedger, prisma } from "./db"; import { getLatestLedger } from "./rpc"; import { getIndexerStats } from "./indexer"; +import { getAllCachedTokens } from "./tokenCache"; // ── Rate limiting ───────────────────────────────────────────────────────────── const limiter = rateLimit({ @@ -171,6 +172,15 @@ 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) => { + const tokens = getAllCachedTokens(); + res.json({ ok: true, tokens }); + }); // ── GET /transfers/incoming/:address ──────────────────────────────────────── /** diff --git a/src/indexer.ts b/src/indexer.ts index ad038311..5e671883 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -8,6 +8,7 @@ import { pruneOldTransfers, } from "./db"; import { emitTransfer } from "./events"; +import { initTokenCache, getTokenMetadata } from "./tokenCache"; // ─── Config ─────────────────────────────────────────────────────────────────── const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS ?? "6000", 10); @@ -77,6 +78,17 @@ async function pollOnce( records.forEach(emitTransfer); } + // Warm the token metadata cache for any new contracts seen in this batch. + // This ensures that metadata is available for API consumers immediately. + const uniqueContracts = [...new Set(records.map((r) => r.contractId))]; + await Promise.all( + uniqueContracts.map((id) => + getTokenMetadata(id).catch((e) => + console.warn(`[indexer] Could not resolve metadata for ${id}:`, e.message) + ) + ) + ); + await setLastIndexedLedger(highestLedger); console.log( @@ -91,6 +103,9 @@ export async function startIndexer(): Promise { // Fail fast if RPC is not configured — surfaces env errors before any DB work validateNetworkConfig(); + // Load existing metadata from DB into memory + await initTokenCache(); + console.log("[indexer] Starting Wraith indexer…"); console.log( `[indexer] Watching contracts: ${CONTRACT_IDS.length > 0 ? CONTRACT_IDS.join(", ") : "ALL"}` diff --git a/src/rpc.ts b/src/rpc.ts index 6f00b738..b24dd5a6 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"; // ─── Network config ─────────────────────────────────────────────────────────── const TESTNET_RPC_URL = "https://soroban-testnet.stellar.org"; @@ -207,3 +207,51 @@ 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): Promise<{ + symbol: string; + decimals: number; + name: string; +}> { + const rpc = getRpc(); + const contract = new Contract(contractId); + const network = (process.env.STELLAR_NETWORK ?? "testnet").toLowerCase(); + const networkPassphrase = network === "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..d0c1fe5f --- /dev/null +++ b/src/tokenCache.ts @@ -0,0 +1,67 @@ +import { prisma } from "./db"; +import { fetchTokenMetadata } from "./rpc"; + +export interface TokenMetadata { + contractId: string; + symbol: string; + name: string; + decimals: number; +} + +// In-memory cache for fast lookups +const cache = new Map(); + +/** + * Populate the in-memory cache from the database on startup. + */ +export async function initTokenCache(): Promise { + try { + const tokens = await prisma.tokenMetadata.findMany(); + for (const token of tokens) { + cache.set(token.contractId, token); + } + console.log(`[cache] Initialized with ${tokens.length} tokens from DB`); + } catch (err) { + console.error("[cache] Failed to initialize token cache from DB:", (err as Error).message); + // Continue anyway; it will fill from RPC as needed + } +} + +/** + * Get token metadata by contractId. + * Checks Memory -> then DB -> then RPC. + */ +export async function getTokenMetadata(contractId: string): Promise { + // 1. Check in-memory cache + const cached = cache.get(contractId); + if (cached) return cached; + + // 2. Check database (in case it was added by another process/instance) + const dbToken = await prisma.tokenMetadata.findUnique({ where: { contractId } }); + if (dbToken) { + cache.set(contractId, dbToken); + return dbToken; + } + + // 3. Fetch from Soroban RPC + console.log(`[cache] Cache miss for ${contractId} — fetching from RPC…`); + const metadata = await fetchTokenMetadata(contractId); + const token: TokenMetadata = { contractId, ...metadata }; + + // 4. Persist to DB and memory + await prisma.tokenMetadata.upsert({ + where: { contractId }, + create: token, + update: token, + }); + + cache.set(contractId, token); + return token; +} + +/** + * Return all tokens currently held in the in-memory cache. + */ +export function getAllCachedTokens(): TokenMetadata[] { + return Array.from(cache.values()); +}