Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c286037
feat: add account-balance zod validation
Roaring30s Jan 26, 2026
c6d6be5
feat: add ens name validation
Roaring30s Jan 26, 2026
39c641d
feat: add score and pending stake validation
Roaring30s Jan 26, 2026
b0b9a78
feat: add ens-data image validation
Roaring30s Jan 26, 2026
95cdbb0
feat: add ens-data index validation
Roaring30s Jan 26, 2026
a109942
feat: add pipelines validation
Roaring30s Jan 27, 2026
a48d74e
feat: add regions validation
Roaring30s Jan 27, 2026
a507701
feat: add treasury proposal state validation
Roaring30s Jan 27, 2026
a057c51
feat: add proposal vote by address validation
Roaring30s Jan 27, 2026
c178ce6
feat: add treasury votes validation
Roaring30s Jan 28, 2026
50900a3
feat: add score validation
Roaring30s Jan 29, 2026
073de6f
feat: add changefeed validation
Roaring30s Jan 29, 2026
20af53f
feat: add contract validation
Roaring30s Jan 29, 2026
0bbdfc8
feat: add current-round validation
Roaring30s Jan 29, 2026
525dfde
feat: add generateProof validation
Roaring30s Jan 30, 2026
19d49ad
feat: add totalTokenSupply validation
Roaring30s Jan 30, 2026
baf3643
feat: add upload ipfs validation
Roaring30s Jan 30, 2026
2be11f8
refactor: refactor usage validation
Roaring30s Jan 30, 2026
27d17eb
feat: add external validation for ens image
Roaring30s Jan 30, 2026
96fe4ec
refactor: refactor ens schema
Roaring30s Jan 30, 2026
dfeeb91
feat: add external checks for score endpoint
Roaring30s Jan 30, 2026
c7f2f41
fix: add guard for projectBySlugs
Roaring30s Jan 30, 2026
86128b2
refactor: refactor schemas
Roaring30s Jan 30, 2026
32c1219
feat: add defense checks to usage endpoint
Roaring30s Jan 30, 2026
d34ca01
feat: add getEnsForAddress validation
Roaring30s Jan 30, 2026
9778492
merge: sync with main
Roaring30s Jan 30, 2026
ff86f3f
Merge branch 'main' into zod-validation
Roaring30s Jan 30, 2026
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 @@ -49,3 +49,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)",
});

/**
* 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" }
);

/**
* 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