Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions prisma/migrations/20260901140000_add_token_metadata/migration.sql
Original file line number Diff line number Diff line change
@@ -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")
);
19 changes: 19 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
146 changes: 146 additions & 0 deletions src/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
14 changes: 14 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ───────────────────────────
/**
Expand Down
16 changes: 16 additions & 0 deletions src/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -414,6 +426,10 @@ export async function startIndexer(network?: Network): Promise<void> {
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.
Expand Down
57 changes: 56 additions & 1 deletion src/rpc.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<any> => {
// 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),
};
}
Loading
Loading