Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ usage.backup.json
.gitignore
bquxjob_1944883c_19a4f7cd5f0.json
usage.json

# typescript
*.tsbuildinfo
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 { z } from "zod";

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

/**
* Validates Ethereum address format (0x followed by 40 hex characters)
* This is stricter than viem's isAddress which also accepts checksummed addresses
*/
export const AddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/, {
message:
"Invalid address format. Must be a valid Ethereum address (0x followed by 40 hex characters)",
});
Comment thread
ECWireless marked this conversation as resolved.

/**
* 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 strict Web URL validation
*/
export const WebUrlSchema = z.string().refine(
(val) => {
try {
new URL(val); // Use native URL constructor
return true;
} catch {
return false;
}
},
{ message: "Invalid URL format" }
);
Comment thread
ECWireless marked this conversation as resolved.

/**
* 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();
Loading
Loading