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
81 changes: 81 additions & 0 deletions apps/ensapi/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import packageJson from "@/../package.json" with { type: "json" };

import { otel } from "@hono/otel";
import { cors } from "hono/cors";
import { html } from "hono/html";

import { errorResponse } from "@/lib/handlers/error-response";
import { createApp } from "@/lib/hono-factory";
import logger from "@/lib/logger";
import { generateOpenApi31Document } from "@/openapi-document";

import realtimeApi from "./handlers/api/meta/realtime-api";
import apiRouter from "./handlers/api/router";
import ensanalyticsApi from "./handlers/ensanalytics/ensanalytics-api";
import ensanalyticsApiV1 from "./handlers/ensanalytics/ensanalytics-api-v1";
import subgraphApi from "./handlers/subgraph/subgraph-api";

const app = createApp();

// set the X-ENSNode-Version response header to the current version
app.use(async (ctx, next) => {
ctx.header("x-ensnode-version", packageJson.version);
return next();
});

// use CORS middleware
app.use(cors({ origin: "*" }));

// include automatic OpenTelemetry instrumentation for incoming requests
app.use(otel());

// host welcome page
app.get("/", (c) =>
c.html(html`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ENSApi</title>
</head>
<body>
<h1>Hello, World!</h1>
<p>You've reached the root of an ENSApi instance. You might be looking for the <a href="https://ensnode.io/docs">ENSNode documentation</a>.</p>
</body>
</html>
`),
);

// use ENSNode HTTP API at /api
app.route("/api", apiRouter);

// use Subgraph GraphQL API at /subgraph
app.route("/subgraph", subgraphApi);

// use ENSAnalytics API at /ensanalytics (v0, implicit)
app.route("/ensanalytics", ensanalyticsApi);

// use ENSAnalytics API v1 at /v1/ensanalytics
app.route("/v1/ensanalytics", ensanalyticsApiV1);

// use Am I Realtime API at /amirealtime
// NOTE: this is legacy endpoint and will be deleted in future. one should use /api/realtime instead
app.route("/amirealtime", realtimeApi);

// generate and return OpenAPI 3.1 document
app.get("/openapi.json", (c) => {
return c.json(generateOpenApi31Document(app));
});

app.get("/health", async (c) => {
return c.json({ message: "fallback ok" });
});

// log hono errors to console
app.onError((error, ctx) => {
logger.error(error);
return errorResponse(ctx, "Internal Server Error");
});

export default app;
82 changes: 45 additions & 37 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,53 @@ import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";

import { ensDbClient } from "@/lib/ensdb/singleton";
import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";

const logger = makeLogger("indexing-status.cache");

export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
ensDbClient
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
.then((snapshot) => {
if (snapshot === undefined) {
// An indexing status snapshot has not been found in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the first snapshot.
// Therefore, throw an error to trigger the subsequent `.catch` handler.
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
}
// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
// SWRCache with proactivelyInitialize:true starts background polling immediately
// on construction, which would trigger ensDbClient before env vars are available.
export const indexingStatusCache = lazyProxy<SWRCache<CrossChainIndexingStatusSnapshot>>(
() =>
new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
ensDbClient
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
.then((snapshot) => {
if (snapshot === undefined) {
// An indexing status snapshot has not been found in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the first snapshot.
// Therefore, throw an error to trigger the subsequent `.catch` handler.
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
}

// The indexing status snapshot has been fetched and successfully validated for caching.
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return snapshot;
})
.catch((error) => {
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
logger.error(
error,
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
`The cached indexing status snapshot (if any) will not be updated.`,
);
throw error;
}),
// We need to refresh the indexing status cache very frequently.
// ENSDb won't have issues handling this frequency of queries.
ttl: 1, // 1 second
proactiveRevalidationInterval: 1, // 1 second
proactivelyInitialize: true,
});
// The indexing status snapshot has been fetched and successfully validated for caching.
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return snapshot;
})
.catch((error) => {
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
logger.error(
error,
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
`The cached indexing status snapshot (if any) will not be updated.`,
);
throw error;
}),
// We need to refresh the indexing status cache very frequently.
// ENSDb won't have issues handling this frequency of queries.
ttl: 1, // 1 second
proactiveRevalidationInterval: 1, // 1 second
proactivelyInitialize: true,
}),
);
22 changes: 15 additions & 7 deletions apps/ensapi/src/cache/referral-program-edition-set.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { minutesToSeconds } from "date-fns";

import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk";

import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";

const logger = makeLogger("referral-program-edition-set-cache");
Expand Down Expand Up @@ -63,6 +64,8 @@ async function loadReferralProgramEditionConfigSet(
return editionConfigSet;
}

type ReferralProgramEditionConfigSetCache = SWRCache<ReferralProgramEditionConfigSet>;

/**
* SWR Cache for the referral program edition config set.
*
Expand All @@ -74,10 +77,15 @@ async function loadReferralProgramEditionConfigSet(
* - proactiveRevalidationInterval: undefined - No proactive revalidation
* - proactivelyInitialize: true - Load immediately on startup
*/
export const referralProgramEditionConfigSetCache = new SWRCache<ReferralProgramEditionConfigSet>({
fn: loadReferralProgramEditionConfigSet,
ttl: Number.POSITIVE_INFINITY,
errorTtl: minutesToSeconds(1),
proactiveRevalidationInterval: undefined,
proactivelyInitialize: true,
});
// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
export const referralProgramEditionConfigSetCache = lazyProxy<ReferralProgramEditionConfigSetCache>(
() =>
new SWRCache<ReferralProgramEditionConfigSet>({
fn: loadReferralProgramEditionConfigSet,
ttl: Number.POSITIVE_INFINITY,
errorTtl: minutesToSeconds(1),
proactiveRevalidationInterval: undefined,
proactivelyInitialize: true,
}),
);
102 changes: 57 additions & 45 deletions apps/ensapi/src/cache/referrer-leaderboard.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,23 @@ import {
} from "@ensnode/ensnode-sdk";

import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard";
import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";

import { indexingStatusCache } from "./indexing-status.cache";

const logger = makeLogger("referrer-leaderboard-cache.cache");

const rules = buildReferralProgramRules(
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
ENS_HOLIDAY_AWARDS_START_DATE,
ENS_HOLIDAY_AWARDS_END_DATE,
getEthnamesSubregistryId(config.namespace),
// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
const rules = lazyProxy(() =>
buildReferralProgramRules(
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
ENS_HOLIDAY_AWARDS_START_DATE,
ENS_HOLIDAY_AWARDS_END_DATE,
getEthnamesSubregistryId(config.namespace),
),
);

/**
Expand All @@ -45,46 +50,53 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
OmnichainIndexingStatusIds.Completed,
];

export const referrerLeaderboardCache = new SWRCache<ReferrerLeaderboard>({
fn: async (_cachedResult) => {
const indexingStatus = await indexingStatusCache.read();
if (indexingStatus instanceof Error) {
throw new Error(
"Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
);
}
type ReferrerLeaderboardCache = SWRCache<ReferrerLeaderboard>;

// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
export const referrerLeaderboardCache = lazyProxy<ReferrerLeaderboardCache>(
() =>
new SWRCache<ReferrerLeaderboard>({
fn: async (_cachedResult) => {
const indexingStatus = await indexingStatusCache.read();
if (indexingStatus instanceof Error) {
throw new Error(
"Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
);
}

const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
throw new Error(
`Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`,
);
}
const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
throw new Error(
`Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`,
);
}

const latestIndexedBlockRef = getLatestIndexedBlockRef(
indexingStatus,
rules.subregistryId.chainId,
);
if (latestIndexedBlockRef === null) {
throw new Error(
`Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`,
);
}
const latestIndexedBlockRef = getLatestIndexedBlockRef(
indexingStatus,
rules.subregistryId.chainId,
);
if (latestIndexedBlockRef === null) {
throw new Error(
`Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`,
);
}

logger.info(`Building referrer leaderboard with rules:\n${JSON.stringify(rules, null, 2)}`);
logger.info(`Building referrer leaderboard with rules:\n${JSON.stringify(rules, null, 2)}`);

try {
const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp);
logger.info(
`Successfully built referrer leaderboard with ${result.referrers.size} referrers from indexed data`,
);
return result;
} catch (error) {
logger.error({ error }, "Failed to build referrer leaderboard");
throw error;
}
},
ttl: minutesToSeconds(1),
proactiveRevalidationInterval: minutesToSeconds(2),
proactivelyInitialize: true,
});
try {
const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp);
logger.info(
`Successfully built referrer leaderboard with ${result.referrers.size} referrers from indexed data`,
);
return result;
} catch (error) {
logger.error({ error }, "Failed to build referrer leaderboard");
throw error;
}
},
ttl: minutesToSeconds(1),
proactiveRevalidationInterval: minutesToSeconds(2),
proactivelyInitialize: true,
}),
);
15 changes: 11 additions & 4 deletions apps/ensapi/src/config/config.singleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ describe("ensdb singleton bootstrap", () => {
});

it("constructs EnsDbReader from real env wiring without errors", async () => {
const { EnsDbReader } = await import("@ensnode/ensdb-sdk");
const { ensDbClient, ensDb, ensIndexerSchema } = await import("@/lib/ensdb/singleton");

expect(ensDbClient).toBeInstanceOf(EnsDbReader);
// ensDbClient is a lazyProxy — construction is deferred until first property access.
// Accessing a property triggers EnsDbReader construction; verify it succeeds.
expect(ensDbClient.ensIndexerSchemaName).toBe(VALID_SCHEMA_NAME);
expect(ensDb).toBeDefined();
expect(ensIndexerSchema).toBeDefined();
});
Expand All @@ -37,7 +38,10 @@ describe("ensdb singleton bootstrap", () => {
const { default: logger } = await import("@/lib/logger");

vi.stubEnv("DATABASE_URL", "");
await expect(import("@/lib/ensdb/singleton")).rejects.toThrow("process.exit");
// ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
// which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
const { ensDbClient } = await import("@/lib/ensdb/singleton");
expect(() => ensDbClient.ensDb).toThrow("process.exit");

expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
Expand All @@ -51,7 +55,10 @@ describe("ensdb singleton bootstrap", () => {
const { default: logger } = await import("@/lib/logger");

vi.stubEnv("ENSINDEXER_SCHEMA_NAME", "");
await expect(import("@/lib/ensdb/singleton")).rejects.toThrow("process.exit");
// ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
// which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
const { ensDbClient } = await import("@/lib/ensdb/singleton");
expect(() => ensDbClient.ensDb).toThrow("process.exit");

expect(logger.error).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
Expand Down
Loading
Loading