Skip to content
Open
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
38 changes: 31 additions & 7 deletions lib/api/ens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { l1Provider } from "@lib/chains";
import { formatAddress } from "@lib/utils";
import sanitizeHtml from "sanitize-html";

import {
GithubHandleSchema,
TwitterHandleSchema,
WebUrlSchema,
} from "./schemas/common";
import { EnsAvatarProviderSchema, EnsTextRecordSchema } from "./schemas/ens";
import { EnsIdentity } from "./types/get-ens";

const sanitizeOptions: sanitizeHtml.IOptions = {
Expand Down Expand Up @@ -59,13 +65,31 @@ export const getEnsForAddress = async (address: string | null | undefined) => {

if (name) {
const resolver = await l1Provider.getResolver(name);
const [description, url, twitter, github, avatar] = await Promise.all([
resolver?.getText("description"),
resolver?.getText("url"),
resolver?.getText("com.twitter"),
resolver?.getText("com.github"),
resolver?.getAvatar(),
]);
const [descriptionRaw, urlRaw, twitterRaw, githubRaw, avatarRaw] =
await Promise.all([
resolver?.getText("description"),
resolver?.getText("url"),
resolver?.getText("com.twitter"),
resolver?.getText("com.github"),
resolver?.getAvatar(),
]);

// Validate all ENS provider responses with graceful fallback
// If validation fails, we set the field to null rather than crashing
const descriptionValidation = EnsTextRecordSchema.safeParse(descriptionRaw);
const urlValidation = WebUrlSchema.nullable().safeParse(urlRaw);
const twitterValidation =
TwitterHandleSchema.nullable().safeParse(twitterRaw);
const githubValidation = GithubHandleSchema.nullable().safeParse(githubRaw);
const avatarValidation = EnsAvatarProviderSchema.safeParse(avatarRaw);

const description = descriptionValidation.success
? descriptionValidation.data
: null;
const url = urlValidation.success ? urlValidation.data : null;
const twitter = twitterValidation.success ? twitterValidation.data : null;
const github = githubValidation.success ? githubValidation.data : null;
const avatar = avatarValidation.success ? avatarValidation.data : null;

const ens: EnsIdentity = {
id: address ?? "",
Expand Down
92 changes: 92 additions & 0 deletions lib/api/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextApiResponse } from "next";
import { z } from "zod";

import { ApiError, ErrorCode } from "./types/api-error";

Expand Down Expand Up @@ -62,3 +63,94 @@ export const methodNotAllowed = (
`Method ${method} Not Allowed`
);
};

/**
* Validates input data against a Zod schema.
* Returns an error response if validation fails.
*
* @param inputResult - The result from Zod's safeParse()
* @param res - Next.js API response object
* @param errorMessage - Error message to return if validation fails (e.g., "Invalid address format")
* @returns The error response if validation failed, undefined otherwise
*/
export const validateInput = <T>(
inputResult:
| { success: true; data: T }
| { success: false; error: z.ZodError<T> },
res: NextApiResponse,
errorMessage: string
): NextApiResponse | undefined => {
if (!inputResult.success) {
badRequest(
res,
errorMessage,
inputResult.error.issues.map((e) => e.message).join(", ")
);
return res;
}
return undefined;
};

/**
* Validates output data against a Zod schema.
* In development, returns an error response if validation fails.
* In production, logs the error but allows execution to continue.
*
* @param outputResult - The result from Zod's safeParse()
* @param res - Next.js API response object
* @param endpointName - Name of the endpoint for logging (e.g., "api/account-balance")
* @returns The error response if validation failed in development, undefined otherwise
*/
export const validateOutput = <T>(
outputResult:
| { success: true; data: T }
| { success: false; error: z.ZodError<T> },
res: NextApiResponse,
endpointName: string
): NextApiResponse | undefined => {
if (!outputResult.success) {
console.error(
`[${endpointName}] Output validation failed:`,
outputResult.error
);
// In production, we might still return the data, but log the error
// In development, this helps catch contract/API changes early
if (process.env.NODE_ENV === "development") {
internalError(
res,
new Error(
`Output validation failed: ${outputResult.error.issues
.map((e) => e.message)
.join(", ")}`
)
);
return res;
}
}
return undefined;
};

/**
* Validates data from an external API against a Zod schema.
* Returns null if validation fails and logs the error.
*
* @param result - The result from Zod's safeParse()
* @param context - Context for logging (e.g. "api/regions")
* @param extraInfo - Additional info for logging (e.g. URL)
* @returns The validated data or null if validation failed
*/
export const validateExternalResponse = <T>(
result: { success: true; data: T } | { success: false; error: z.ZodError<T> },
context: string,
extraInfo?: string
): T | null => {
if (!result.success) {
console.error(
`[${context}] External API response validation failed:`,
result.error,
extraInfo || ""
);
return null;
}
return result.data;
};
31 changes: 31 additions & 0 deletions lib/api/schemas/changefeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { z } from "zod";

const ChangeSchema = z.object({
type: z.string(),
content: z.string(),
});

export const ChangefeedReleaseSchema = z.object({
title: z.string(),
description: z.string().nullable(),
isPublished: z.boolean(),
publishedAt: z.string(),
changes: z.array(ChangeSchema),
});

export const ChangefeedResponseSchema = z.object({
name: z.string(),
releases: z.object({
edges: z.array(
z.object({
node: ChangefeedReleaseSchema,
})
),
}),
});

export const ChangefeedGraphQLResultSchema = z.object({
data: z.object({
projectBySlugs: z.unknown(),
}),
});
84 changes: 84 additions & 0 deletions lib/api/schemas/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { isAddress } from "viem";
import { z } from "zod";

/**
* Common schemas used across multiple API endpoints
*/

/**
* Validates Ethereum addresses using viem.
* Accepts valid lowercase/uppercase addresses and valid EIP-55 mixed-case addresses.
*/
export const AddressSchema = z.string().refine(isAddress, {
message: "Invalid Ethereum address",
});

/**
* Schema for account balance API response
*/
export const AccountBalanceSchema = z.object({
balance: z.string(),
allowance: z.string(),
});

/**
* Validates a numeric string (for BigNumber values)
*/
export const NumericStringSchema = z.string().regex(/^\d+$/, {
message: "Must be a numeric string",
});

/**
* Validates optional query parameters
*/
export const OptionalStringSchema = z.string().optional();

/**
* Validates a region string (non-empty)
*/
export const RegionSchema = z.string().min(1, "Region cannot be empty");

/**
* Schema for a single region object
*/
export const RegionObjectSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(["transcoding", "ai"]),
});

/**
* Schema for regions API response
*/
export const RegionsSchema = z.object({
regions: z.array(RegionObjectSchema),
});

/**
* Schema for web URLs with an explicit http/https protocol requirement.
*/
export const WebUrlSchema = z.string().refine(
(val) => {
try {
const parsedUrl = new URL(val);
return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
} catch {
return false;
}
},
{ message: "Invalid URL format. Must use http or https." }
);

/**
* Standard Twitter/X handle check: alphanumeric + underscore, max 15 chars (excludes @)
*/
export const TwitterHandleSchema = z
.string()
.regex(/^[A-Za-z0-9_]{1,15}$/, "Invalid Twitter handle");

/**
* Standard GitHub handle check: alphanumeric + hyphens, max 39 chars
*/
export const GithubHandleSchema = z
.string()
.regex(/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i, "Invalid GitHub handle");
25 changes: 25 additions & 0 deletions lib/api/schemas/contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { z } from "zod";

const ContractLinkSchema = z.object({
name: z.string(),
address: z.string(),
link: z.string(),
});

export const ContractInfoSchema = z.object({
Controller: ContractLinkSchema.nullable(),
L1Migrator: ContractLinkSchema.nullable(),
L2Migrator: ContractLinkSchema.nullable(),
PollCreator: ContractLinkSchema.nullable(),
BondingManager: ContractLinkSchema.nullable(),
LivepeerToken: ContractLinkSchema.nullable(),
LivepeerTokenFaucet: ContractLinkSchema.nullable(),
MerkleSnapshot: ContractLinkSchema.nullable(),
Minter: ContractLinkSchema.nullable(),
RoundsManager: ContractLinkSchema.nullable(),
ServiceRegistry: ContractLinkSchema.nullable(),
TicketBroker: ContractLinkSchema.nullable(),
LivepeerGovernor: ContractLinkSchema.nullable(),
Treasury: ContractLinkSchema.nullable(),
BondingVotes: ContractLinkSchema.nullable(),
});
9 changes: 9 additions & 0 deletions lib/api/schemas/current-round.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from "zod";

export const CurrentRoundInfoSchema = z.object({
id: z.number(),
startBlock: z.number(),
initialized: z.boolean(),
currentL1Block: z.number(),
currentL2Block: z.number(),
});
79 changes: 79 additions & 0 deletions lib/api/schemas/ens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { z } from "zod";

import {
AddressSchema,
GithubHandleSchema,
TwitterHandleSchema,
WebUrlSchema,
} from "./common";

/**
* Schema for ENS identity data
*/
export const EnsIdentitySchema = z.object({
id: z.string(),
idShort: z.string(),
avatar: z.string().nullable().optional(),
name: z.string().nullable().optional(),
// Strict validation that falls back to null if invalid
url: WebUrlSchema.nullable().optional().catch(null),
twitter: TwitterHandleSchema.nullable().optional().catch(null),
github: GithubHandleSchema.nullable().optional().catch(null),
description: z.string().nullable().optional(),
isLoading: z.boolean().optional(),
});

/**
* Blacklist of addresses that should be rejected
*/
const ENS_BLACKLIST = ["0xcb69ffc06d3c218472c50ee25f5a1d3ca9650c44"].map((a) =>
a.toLowerCase()
);

/**
* Blacklist of ENS names that should be rejected
*/
const ENS_NAME_BLACKLIST = ["salty-minning.eth"];

/**
* Address schema with blacklist validation for ENS endpoints
*/
export const EnsAddressSchema = AddressSchema.refine(
(address) => !ENS_BLACKLIST.includes(address.toLowerCase()),
{
message: "Address is blacklisted",
}
);

/**
* Schema for ENS name validation (with blacklist check)
*/
export const EnsNameSchema = z
.string()
.min(1, "ENS name cannot be empty")
.refine((name) => !ENS_NAME_BLACKLIST.includes(name), {
message: "ENS name is blacklisted",
});

/**
* Schema for array of ENS identities
*/
export const EnsIdentityArraySchema = z.array(EnsIdentitySchema);

export const EnsAvatarResultSchema = z.string().nullable();

/**
* Schema for ENS text record responses from provider
* Validates that text records are strings (or null if not set)
*/
export const EnsTextRecordSchema = z.string().nullable();

/**
* Schema for ENS avatar response from provider
* Validates the avatar object structure returned by getAvatar()
*/
export const EnsAvatarProviderSchema = z
.object({
url: z.string(),
})
.nullable();
12 changes: 12 additions & 0 deletions lib/api/schemas/generate-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from "zod";

import { AddressSchema, NumericStringSchema } from "./common";

export const GenerateProofInputSchema = z.object({
account: AddressSchema,
delegate: AddressSchema,
stake: NumericStringSchema,
fees: NumericStringSchema,
});

export const GenerateProofOutputSchema = z.array(z.string());
Loading
Loading