diff --git a/.gitignore b/.gitignore index 8c781c32..0d9498c3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ usage.backup.json .gitignore bquxjob_1944883c_19a4f7cd5f0.json usage.json + +# typescript +*.tsbuildinfo diff --git a/lib/api/ens.ts b/lib/api/ens.ts index 777fd7a8..4e9de877 100644 --- a/lib/api/ens.ts +++ b/lib/api/ens.ts @@ -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 = { @@ -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 ?? "", diff --git a/lib/api/errors.ts b/lib/api/errors.ts index 90c86ea0..932526a2 100644 --- a/lib/api/errors.ts +++ b/lib/api/errors.ts @@ -1,4 +1,5 @@ import { NextApiResponse } from "next"; +import { z } from "zod"; import { ApiError, ErrorCode } from "./types/api-error"; @@ -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 = ( + inputResult: + | { success: true; data: T } + | { success: false; error: z.ZodError }, + 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 = ( + outputResult: + | { success: true; data: T } + | { success: false; error: z.ZodError }, + 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 = ( + result: { success: true; data: T } | { success: false; error: z.ZodError }, + 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; +}; diff --git a/lib/api/schemas/changefeed.ts b/lib/api/schemas/changefeed.ts new file mode 100644 index 00000000..20417ace --- /dev/null +++ b/lib/api/schemas/changefeed.ts @@ -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(), + }), +}); diff --git a/lib/api/schemas/common.ts b/lib/api/schemas/common.ts new file mode 100644 index 00000000..707564c2 --- /dev/null +++ b/lib/api/schemas/common.ts @@ -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"); diff --git a/lib/api/schemas/contracts.ts b/lib/api/schemas/contracts.ts new file mode 100644 index 00000000..d65daa9a --- /dev/null +++ b/lib/api/schemas/contracts.ts @@ -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(), +}); diff --git a/lib/api/schemas/current-round.ts b/lib/api/schemas/current-round.ts new file mode 100644 index 00000000..5477b10b --- /dev/null +++ b/lib/api/schemas/current-round.ts @@ -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(), +}); diff --git a/lib/api/schemas/ens.ts b/lib/api/schemas/ens.ts new file mode 100644 index 00000000..d8d04160 --- /dev/null +++ b/lib/api/schemas/ens.ts @@ -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(); diff --git a/lib/api/schemas/generate-proof.ts b/lib/api/schemas/generate-proof.ts new file mode 100644 index 00000000..63d00641 --- /dev/null +++ b/lib/api/schemas/generate-proof.ts @@ -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()); diff --git a/lib/api/schemas/index.ts b/lib/api/schemas/index.ts new file mode 100644 index 00000000..09b9737a --- /dev/null +++ b/lib/api/schemas/index.ts @@ -0,0 +1,10 @@ +/** + * Central export for all Zod schemas + * This allows importing schemas from a single location + */ + +export * from "./common"; +export * from "./ens"; +export * from "./performance"; +export * from "./staking"; +export * from "./treasury"; diff --git a/lib/api/schemas/performance.ts b/lib/api/schemas/performance.ts new file mode 100644 index 00000000..db121e01 --- /dev/null +++ b/lib/api/schemas/performance.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +import { AddressSchema } from "./common"; + +/** + * Performance metrics and scoring schemas + */ + +/** + * Schema for performance metrics + */ +export const MetricSchema = z.object({ + success_rate: z.number(), + round_trip_score: z.number(), + score: z.number(), +}); + +/** + * Schema for metrics response (nested record structure) + */ +export const MetricsResponseSchema = z.record( + z.string(), + z.record(z.string(), MetricSchema).optional() +); + +/** + * Schema for score response + */ +export const ScoreResponseSchema = z.object({ + value: z.number(), + region: z.string(), + model: z.string(), + pipeline: z.string(), + orchestrator: z.string(), +}); + +/** + * Schema for regional values (key-value pairs of region to number) + */ +export const RegionalValuesSchema = z.record(z.string(), z.number()); + +/** + * Schema for performance metrics response + */ +export const PerformanceMetricsSchema = z.object({ + successRates: RegionalValuesSchema, + roundTripScores: RegionalValuesSchema, + scores: RegionalValuesSchema, + pricePerPixel: z.number(), + topAIScore: z.preprocess( + (val) => + typeof val === "object" && val !== null && Object.keys(val).length === 0 + ? undefined + : val, + ScoreResponseSchema.optional().nullable() + ), +}); + +/** + * Schema for pipeline + */ +export const PipelineSchema = z.object({ + id: z.string(), + models: z.array(z.string()), + regions: z.array(z.string()), +}); + +/** + * Schema for available pipelines response + */ +export const AvailablePipelinesSchema = z.object({ + pipelines: z.array(PipelineSchema), +}); + +/** + * Schema for all performance metrics (record of address to performance metrics) + */ +export const AllPerformanceMetricsSchema = z.record( + AddressSchema, + PerformanceMetricsSchema +); + +/** + * Schema for pipeline query parameters + */ +export const PipelineQuerySchema = z.object({ + pipeline: z.string().optional(), + model: z.string().optional(), +}); + +/** + * Schema for pricing API response + */ +export const PriceResponseSchema = z.array( + z.object({ + Address: z.string(), + ServiceURI: z.string().optional(), + LastRewardRound: z.number().optional(), + RewardCut: z.number().optional(), + FeeShare: z.number().optional(), + DelegatedStake: z.coerce.string().optional(), + ActivationRound: z.number().optional(), + DeactivationRound: z.coerce.string().optional(), + Active: z.boolean().optional(), + Status: z.string().optional(), + PricePerPixel: z.number(), + UpdatedAt: z.number().optional(), + }) +); diff --git a/lib/api/schemas/staking.ts b/lib/api/schemas/staking.ts new file mode 100644 index 00000000..53da1744 --- /dev/null +++ b/lib/api/schemas/staking.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +/** + * Staking and delegator-related schemas + */ + +/** + * Schema for pending fees and stake response + */ +export const PendingFeesAndStakeSchema = z.object({ + pendingStake: z.string(), + pendingFees: z.string(), +}); + +/** + * Schema for unbonding lock + */ +export const UnbondingLockSchema = z.object({ + id: z.number(), + amount: z.string(), + withdrawRound: z.string(), +}); + +/** + * Schema for L1 delegator response + */ +export const L1DelegatorSchema = z.object({ + delegateAddress: z.string(), + pendingStake: z.string(), + pendingFees: z.string(), + transcoderStatus: z.enum(["not-registered", "registered"]), + unbondingLocks: z.array(UnbondingLockSchema), + activeLocks: z.array(UnbondingLockSchema), +}); diff --git a/lib/api/schemas/subgraph.ts b/lib/api/schemas/subgraph.ts new file mode 100644 index 00000000..99d744fb --- /dev/null +++ b/lib/api/schemas/subgraph.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +/** + * Generic Subgraph Response Schema + * Validates the standard { data: { ... } } wrapper from The Graph + */ +export const SubgraphResponseSchema = z.object({ + data: z.record(z.string(), z.unknown()).optional(), + errors: z.array(z.unknown()).optional(), +}); + +/** + * Specific Envelope for Current Round Subgraph Query + */ +export const CurrentRoundSubgraphResultSchema = z.object({ + data: z.object({ + protocol: z + .object({ + currentRound: z + .object({ + id: z.string().or(z.number()), + startBlock: z.string().or(z.number()), + initialized: z.boolean(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + _meta: z + .object({ + block: z + .object({ + number: z.number(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + }), +}); + +/** + * Schema for subgraph livepeer account response + */ +export const LivepeerAccountSchema = z.object({ + id: z.string(), +}); + +/** + * Schema for subgraph response structure for Livepeer Accounts + */ +export const LivepeerAccountsSubgraphSchema = z.object({ + data: z.object({ + livepeerAccounts: z.array(LivepeerAccountSchema).nullable().optional(), + }), +}); diff --git a/lib/api/schemas/total-token-supply.ts b/lib/api/schemas/total-token-supply.ts new file mode 100644 index 00000000..0d3fdce7 --- /dev/null +++ b/lib/api/schemas/total-token-supply.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const SubgraphTotalSupplyResponseSchema = z.object({ + data: z.object({ + protocol: z.object({ + totalSupply: z.string(), + }), + }), +}); + +export const TotalTokenSupplyOutputSchema = z.number(); diff --git a/lib/api/schemas/treasury.ts b/lib/api/schemas/treasury.ts new file mode 100644 index 00000000..57fc00b4 --- /dev/null +++ b/lib/api/schemas/treasury.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; + +import { AddressSchema } from "./common"; + +/** + * Treasury and proposal-related schemas + */ + +/** + * Validates a proposal ID (numeric string) + */ +export const ProposalIdSchema = z.string().regex(/^\d+$/, { + message: "Proposal ID must be a numeric string", +}); + +/** + * Schema for treasury proposal state + */ +export const TreasuryProposalStateSchema = z.object({ + state: z.enum([ + "pending", + "active", + "canceled", + "defeated", + "succeeded", + "queued", + "expired", + "executed", + ]), +}); + +/** + * Schema for proposal state API response + * Matches the ProposalState type from get-treasury-proposal.ts + */ +export const ProposalStateSchema = z.object({ + id: z.string(), + state: z.enum([ + "Pending", + "Active", + "Canceled", + "Defeated", + "Succeeded", + "Queued", + "Expired", + "Executed", + "Unknown", + ]), + quota: z.string(), + quorum: z.string(), + totalVoteSupply: z.string(), + votes: z.object({ + against: z.string(), + for: z.string(), + abstain: z.string(), + }), +}); + +/** + * Schema for proposal voting power API response + * Matches the ProposalVotingPower type from get-treasury-proposal.ts + */ +export const ProposalVotingPowerSchema = z.object({ + self: z.object({ + address: z.string().regex(/^0x[a-fA-F0-9]{40}$/), + votes: z.string(), + hasVoted: z.boolean(), + }), + delegate: z + .object({ + address: z.string().regex(/^0x[a-fA-F0-9]{40}$/), + votes: z.string(), + hasVoted: z.boolean(), + }) + .optional(), +}); + +/** + * Schema for treasury vote data + */ +export const TreasuryVoteSchema = z + .object({ + proposalId: z.string(), + support: z.boolean(), + votes: z.string(), + hasVoted: z.boolean(), + }) + .optional(); + +/** + * Schema for treasury voting power response + * Matches the VotingPower type from get-treasury-proposal.ts + */ +export const VotingPowerSchema = z.object({ + proposalThreshold: z.string(), + self: z.object({ + address: AddressSchema, + votes: z.string(), + }), + delegate: z + .object({ + address: AddressSchema, + votes: z.string(), + }) + .optional(), +}); + +/** + * Schema for registered to vote response + * Matches the RegisteredToVote type from get-treasury-proposal.ts + */ +export const RegisteredToVoteSchema = z.object({ + registered: z.boolean(), + delegate: z.object({ + address: AddressSchema, + registered: z.boolean(), + }), +}); diff --git a/lib/api/schemas/upload-ipfs.ts b/lib/api/schemas/upload-ipfs.ts new file mode 100644 index 00000000..695d5294 --- /dev/null +++ b/lib/api/schemas/upload-ipfs.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +const PollProposalSchema = z.object({ + gitCommitHash: z.string().length(40), + // Limit text to 500KB (500,000 characters) - plenty for a proposal, small enough to prevent abuse + text: z.string().max(500000), +}); + +export const UploadIpfsInputSchema = z.union([PollProposalSchema, z.never()]); + +export const PinataPinResponseSchema = z.object({ + IpfsHash: z.string(), +}); + +export const UploadIpfsOutputSchema = z.object({ + hash: z.string(), +}); diff --git a/lib/api/schemas/usage.ts b/lib/api/schemas/usage.ts new file mode 100644 index 00000000..33911914 --- /dev/null +++ b/lib/api/schemas/usage.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +export const DayDataSchema = z.array( + z.object({ + dateS: z.number(), + volumeEth: z + .number() + .nullish() + .transform((val) => val ?? 0), + volumeUsd: z + .number() + .nullish() + .transform((val) => val ?? 0), + feeDerivedMinutes: z + .number() + .nullish() + .transform((val) => val ?? 0), + participationRate: z + .number() + .nullish() + .transform((val) => val ?? 0), + inflation: z + .number() + .nullish() + .transform((val) => val ?? 0), + activeTranscoderCount: z + .number() + .nullish() + .transform((val) => val ?? 0), + delegatorsCount: z + .number() + .nullish() + .transform((val) => val ?? 0), + }) +); + +// Helper schema for chart structure +const WeeklyDataSchema = z.object({ + date: z.number(), + weeklyVolumeUsd: z.number(), + weeklyVolumeEth: z.number(), + weeklyUsageMinutes: z.number(), +}); + +export const UsageOutputSchema = z.object({ + dayData: DayDataSchema, // Reusing the schema above strictly for structure + weeklyData: z.array(WeeklyDataSchema), + oneDayVolumeUSD: z.number(), + oneDayVolumeETH: z.number(), + oneWeekVolumeUSD: z.number(), + oneWeekVolumeETH: z.number(), + oneDayUsage: z.number(), + oneWeekUsage: z.number(), + dailyUsageChange: z.number(), + weeklyUsageChange: z.number(), + weeklyVolumeChangeUSD: z.number(), + weeklyVolumeChangeETH: z.number(), + volumeChangeUSD: z.number(), + volumeChangeETH: z.number(), + participationRateChange: z.number(), + inflationChange: z.number(), + delegatorsCountChange: z.number(), + activeTranscoderCountChange: z.number(), + participationRate: z.number(), + inflation: z.number(), + activeTranscoderCount: z.number(), + delegatorsCount: z.number(), +}); diff --git a/next-env.d.ts b/next-env.d.ts index 7996d352..19709046 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/pages/api/account-balance/[address].tsx b/pages/api/account-balance/[address].tsx index 0eab65b5..929fd7f3 100644 --- a/pages/api/account-balance/[address].tsx +++ b/pages/api/account-balance/[address].tsx @@ -4,11 +4,16 @@ import { getBondingManagerAddress, getLivepeerTokenAddress, } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { AccountBalanceSchema, AddressSchema } from "@lib/api/schemas"; import { AccountBalance } from "@lib/api/types/get-account-balance"; import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; -import { isAddress } from "viem"; import { Address } from "viem"; const handler = async ( @@ -23,33 +28,49 @@ const handler = async ( const { address } = req.query; - if (!!address && !Array.isArray(address) && isAddress(address)) { - const livepeerTokenAddress = await getLivepeerTokenAddress(); - const bondingManagerAddress = await getBondingManagerAddress(); - - const balance = await l2PublicClient.readContract({ - address: livepeerTokenAddress, - abi: livepeerToken, - functionName: "balanceOf", - args: [address as Address], - }); - - const allowance = await l2PublicClient.readContract({ - address: livepeerTokenAddress, - abi: livepeerToken, - functionName: "allowance", - args: [address as Address, bondingManagerAddress as Address], - }); - - const accountBalance: AccountBalance = { - balance: balance.toString(), - allowance: allowance.toString(), - }; - - return res.status(200).json(accountBalance); - } else { - return badRequest(res, "Invalid address format"); - } + // AddressSchema handles undefined, arrays, and validates format + const addressResult = AddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; + + const validatedAddress = addressResult.data; + + const livepeerTokenAddress = await getLivepeerTokenAddress(); + const bondingManagerAddress = await getBondingManagerAddress(); + + const balance = await l2PublicClient.readContract({ + address: livepeerTokenAddress, + abi: livepeerToken, + functionName: "balanceOf", + args: [validatedAddress as Address], + }); + + const allowance = await l2PublicClient.readContract({ + address: livepeerTokenAddress, + abi: livepeerToken, + functionName: "allowance", + args: [validatedAddress as Address, bondingManagerAddress as Address], + }); + + const accountBalance: AccountBalance = { + balance: balance.toString(), + allowance: allowance.toString(), + }; + + // Validate output: account balance response + const outputResult = AccountBalanceSchema.safeParse(accountBalance); + const outputValidationError = validateOutput( + outputResult, + res, + "api/account-balance" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(accountBalance); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/changefeed.tsx b/pages/api/changefeed.tsx index 55e1ebe7..43be3e5e 100644 --- a/pages/api/changefeed.tsx +++ b/pages/api/changefeed.tsx @@ -3,7 +3,12 @@ import { externalApiError, internalError, methodNotAllowed, + validateOutput, } from "@lib/api/errors"; +import { + ChangefeedGraphQLResultSchema, + ChangefeedResponseSchema, +} from "@lib/api/schemas/changefeed"; import { fetchWithRetry } from "@lib/fetchWithRetry"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -55,9 +60,26 @@ const changefeed = async (_req: NextApiRequest, res: NextApiResponse) => { res.setHeader("Cache-Control", getCacheControlHeader("hour")); - const { - data: { projectBySlugs }, - } = await response.json(); + const json = await response.json(); + + // Validate GraphQL envelope structure + const envelopeResult = ChangefeedGraphQLResultSchema.safeParse(json); + if (!envelopeResult.success) { + return externalApiError( + res, + "changefeed.app", + "Invalid GraphQL structure" + ); + } + + const { projectBySlugs } = envelopeResult.data.data; + + const validationResult = + ChangefeedResponseSchema.safeParse(projectBySlugs); + if (validateOutput(validationResult, res, "changefeed")) { + return; + } + return res.status(200).json(projectBySlugs); } diff --git a/pages/api/contracts.tsx b/pages/api/contracts.tsx index 0422e594..b106646d 100644 --- a/pages/api/contracts.tsx +++ b/pages/api/contracts.tsx @@ -5,7 +5,12 @@ import { getLivepeerGovernorAddress, getTreasuryAddress, } from "@lib/api/contracts"; -import { internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateOutput, +} from "@lib/api/errors"; +import { ContractInfoSchema } from "@lib/api/schemas/contracts"; import { ContractInfo } from "@lib/api/types/get-contract-info"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; @@ -124,6 +129,11 @@ const handler = async ( }, }; + const validationResult = ContractInfoSchema.safeParse(contractsInfo); + if (validateOutput(validationResult, res, "contracts")) { + return; + } + return res.status(200).json(contractsInfo); } diff --git a/pages/api/current-round.tsx b/pages/api/current-round.tsx index d0a64b58..6d31119b 100644 --- a/pages/api/current-round.tsx +++ b/pages/api/current-round.tsx @@ -1,5 +1,12 @@ import { getCacheControlHeader, getCurrentRound } from "@lib/api"; -import { internalError, methodNotAllowed } from "@lib/api/errors"; +import { + externalApiError, + internalError, + methodNotAllowed, + validateOutput, +} from "@lib/api/errors"; +import { CurrentRoundInfoSchema } from "@lib/api/schemas/current-round"; +import { CurrentRoundSubgraphResultSchema } from "@lib/api/schemas/subgraph"; import { CurrentRoundInfo } from "@lib/api/types/get-current-round"; import { l1PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; @@ -14,9 +21,21 @@ const handler = async ( if (method === "GET") { res.setHeader("Cache-Control", getCacheControlHeader("minute")); + const response = await getCurrentRound(); + + const subgraphValidationResult = + CurrentRoundSubgraphResultSchema.safeParse(response); + if (!subgraphValidationResult.success) { + return externalApiError( + res, + "subgraph", + "Invalid response structure from subgraph" + ); + } + const { data: { protocol, _meta }, - } = await getCurrentRound(); + } = subgraphValidationResult.data; const currentRound = protocol?.currentRound; if (!currentRound) { @@ -40,6 +59,11 @@ const handler = async ( currentL2Block: Number(currentL2Block), }; + const validationResult = CurrentRoundInfoSchema.safeParse(roundInfo); + if (validateOutput(validationResult, res, "current-round")) { + return; + } + return res.status(200).json(roundInfo); } diff --git a/pages/api/ens-data/[address].tsx b/pages/api/ens-data/[address].tsx index 7d4cb178..a6aa1638 100644 --- a/pages/api/ens-data/[address].tsx +++ b/pages/api/ens-data/[address].tsx @@ -1,13 +1,15 @@ import { getCacheControlHeader } from "@lib/api"; import { getEnsForAddress } from "@lib/api/ens"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { EnsAddressSchema, EnsIdentitySchema } from "@lib/api/schemas"; import { EnsIdentity } from "@lib/api/types/get-ens"; import { NextApiRequest, NextApiResponse } from "next"; -import { Address, isAddress } from "viem"; - -const blacklist = ["0xcb69ffc06d3c218472c50ee25f5a1d3ca9650c44"].map((a) => - a.toLowerCase() -); +import { Address } from "viem"; const handler = async ( req: NextApiRequest, @@ -17,22 +19,33 @@ const handler = async ( const method = req.method; if (method === "GET") { - const { address } = req.query; - res.setHeader("Cache-Control", getCacheControlHeader("week")); - if ( - !!address && - !Array.isArray(address) && - isAddress(address) && - !blacklist.includes(address.toLowerCase()) - ) { - const ens = await getEnsForAddress(address as Address); - - return res.status(200).json(ens); - } else { - return badRequest(res, "Invalid address format"); - } + const { address } = req.query; + + // EnsAddressSchema handles undefined, arrays, and validates format + blacklist + const addressResult = EnsAddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; + + const validatedAddress = addressResult.data; + + const ens = await getEnsForAddress(validatedAddress as Address); + + // Validate output: ENS identity response + const outputResult = EnsIdentitySchema.safeParse(ens); + const outputValidationError = validateOutput( + outputResult, + res, + "api/ens-data" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(ens); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/ens-data/image/[name].tsx b/pages/api/ens-data/image/[name].tsx index d2be5f8b..eda8b232 100644 --- a/pages/api/ens-data/image/[name].tsx +++ b/pages/api/ens-data/image/[name].tsx @@ -1,17 +1,17 @@ import { getCacheControlHeader } from "@lib/api"; import { - badRequest, internalError, methodNotAllowed, notFound, + validateInput, } from "@lib/api/errors"; +import { EnsAvatarResultSchema, EnsNameSchema } from "@lib/api/schemas"; +import { WebUrlSchema } from "@lib/api/schemas/common"; import { l1PublicClient } from "@lib/chains"; import { parseArweaveTxId, parseCid } from "livepeer/utils"; import { NextApiRequest, NextApiResponse } from "next"; import { normalize } from "viem/ens"; -const blacklist = ["salty-minning.eth"]; - const handler = async ( req: NextApiRequest, res: NextApiResponse @@ -22,41 +22,65 @@ const handler = async ( if (method === "GET") { const { name } = req.query; - if ( - name && - typeof name === "string" && - name.length > 0 && - !blacklist.includes(name) - ) { - try { - const avatar = await l1PublicClient.getEnsAvatar({ - name: normalize(name), - }); - - const cid = parseCid(avatar); - const arweaveId = parseArweaveTxId(avatar); - - const imageUrl = cid?.id - ? `https://dweb.link/ipfs/${cid.id}` - : arweaveId?.id - ? arweaveId.url - : avatar?.startsWith("https://") - ? avatar - : `https://metadata.ens.domains/mainnet/avatar/${name}`; - - const response = await fetch(imageUrl); - - const arrayBuffer = await response.arrayBuffer(); - - res.setHeader("Cache-Control", getCacheControlHeader("week")); - - return res.end(Buffer.from(arrayBuffer)); - } catch (e) { - console.error(e); - return notFound(res, "ENS avatar not found"); + // EnsNameSchema handles undefined, arrays, and validates format + blacklist + const nameResult = EnsNameSchema.safeParse(name); + const inputValidationError = validateInput( + nameResult, + res, + "Invalid ENS name" + ); + if (inputValidationError) return inputValidationError; + + if (!nameResult.success) { + return internalError(res, new Error("ENS name validation failed")); + } + + const validatedName = nameResult.data; + + try { + const rawAvatar = await l1PublicClient.getEnsAvatar({ + name: normalize(validatedName), + }); + + const avatarValidation = EnsAvatarResultSchema.safeParse(rawAvatar); + + if (!avatarValidation.success) { + return internalError( + res, + new Error("Invalid avatar data from RPC provider") + ); } - } else { - return badRequest(res, "Invalid ENS name"); + + const avatar = avatarValidation.data; + + const cid = parseCid(avatar); + const arweaveId = parseArweaveTxId(avatar); + + const imageUrl = cid?.id + ? `https://dweb.link/ipfs/${cid.id}` + : arweaveId?.id + ? arweaveId.url + : avatar?.startsWith("https://") + ? avatar + : `https://metadata.ens.domains/mainnet/avatar/${validatedName}`; + + // Extra validation to prevent SSRF - Server Side Request Forgery + const urlValidation = WebUrlSchema.safeParse(imageUrl); + + if (!urlValidation.success) { + return notFound(res, "Invalid or missing ENS avatar URL"); + } + + const response = await fetch(urlValidation.data); + + const arrayBuffer = await response.arrayBuffer(); + + res.setHeader("Cache-Control", getCacheControlHeader("week")); + + return res.end(Buffer.from(arrayBuffer)); + } catch (e) { + console.error(e); + return notFound(res, "ENS avatar not found"); } } diff --git a/pages/api/ens-data/index.tsx b/pages/api/ens-data/index.tsx index 51ae19c8..8eb38c0a 100644 --- a/pages/api/ens-data/index.tsx +++ b/pages/api/ens-data/index.tsx @@ -1,6 +1,13 @@ import { getCacheControlHeader } from "@lib/api"; import { getEnsForAddress } from "@lib/api/ens"; -import { internalError, methodNotAllowed } from "@lib/api/errors"; +import { + externalApiError, + internalError, + methodNotAllowed, + validateOutput, +} from "@lib/api/errors"; +import { AddressSchema, EnsIdentityArraySchema } from "@lib/api/schemas"; +import { LivepeerAccountsSubgraphSchema } from "@lib/api/schemas/subgraph"; import { EnsIdentity } from "@lib/api/types/get-ens"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "@lib/chains"; import { fetchWithRetry } from "@lib/fetchWithRetry"; @@ -43,13 +50,54 @@ const handler = async ( } ); - const { - data: { livepeerAccounts }, - } = await response.json(); + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + console.error( + "Subgraph fetch error:", + response.status, + errorText, + `URL: ${CHAIN_INFO[DEFAULT_CHAIN_ID].subgraph}` + ); + return externalApiError( + res, + "subgraph", + `Status ${response.status}: ${errorText}` + ); + } - const addresses: string[] = livepeerAccounts - ?.map((a) => a?.id) - .filter((e) => e); + const responseData = await response.json(); + + // Validate external API response: subgraph response structure + const subgraphResult = + LivepeerAccountsSubgraphSchema.safeParse(responseData); + if (!subgraphResult.success) { + console.error( + "[api/ens-data] Subgraph response validation failed:", + subgraphResult.error + ); + return externalApiError( + res, + "subgraph", + "Invalid response structure from subgraph" + ); + } + + const { livepeerAccounts } = subgraphResult.data.data; + + // Validate and filter addresses + const addresses: string[] = (livepeerAccounts || []) + .map((a) => a.id) + .filter((id) => { + const addressResult = AddressSchema.safeParse(id); + if (!addressResult.success) { + console.warn( + `[api/ens-data] Invalid address from subgraph: ${id}`, + addressResult.error.issues.map((e) => e.message).join(", ") + ); + return false; + } + return true; + }); const ensAddresses: EnsIdentity[] = ( await Promise.all( @@ -65,6 +113,15 @@ const handler = async ( .filter((e) => e) .map((e) => e!); + // Validate output: array of ENS identities + const outputResult = EnsIdentityArraySchema.safeParse(ensAddresses); + const validationError = validateOutput( + outputResult, + res, + "api/ens-data/index" + ); + if (validationError) return validationError; + return res.status(200).json(ensAddresses); } diff --git a/pages/api/generateProof.tsx b/pages/api/generateProof.tsx index 70221aeb..6a4b7028 100644 --- a/pages/api/generateProof.tsx +++ b/pages/api/generateProof.tsx @@ -1,4 +1,13 @@ -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { + GenerateProofInputSchema, + GenerateProofOutputSchema, +} from "@lib/api/schemas/generate-proof"; import { DEFAULT_CHAIN_ID } from "@lib/chains"; import { EarningsTree } from "@lib/earningsTree"; import { utils } from "ethers"; @@ -13,15 +22,17 @@ const generateProof = async (_req: NextApiRequest, res: NextApiResponse) => { const method = _req.method; if (method === "POST") { - const { account, delegate, stake, fees } = _req.body; + const inputValidation = GenerateProofInputSchema.safeParse(_req.body); - if (!account || !delegate || stake === undefined || fees === undefined) { - return badRequest( + // Explicit check required for TypeScript type narrowing + if (!inputValidation.success) { + return validateInput( + inputValidation, res, - "Missing required parameters", "account, delegate, stake, and fees are required" ); } + const { account, delegate, stake, fees } = inputValidation.data; // generate the merkle tree from JSON const tree = EarningsTree.fromJSON( @@ -38,6 +49,11 @@ const generateProof = async (_req: NextApiRequest, res: NextApiResponse) => { const proof = tree.getHexProof(leaf); + const outputValidation = GenerateProofOutputSchema.safeParse(proof); + if (validateOutput(outputValidation, res, "generateProof")) { + return; + } + return res.status(200).json(proof); } diff --git a/pages/api/l1-delegator/[address].tsx b/pages/api/l1-delegator/[address].tsx index d9a38666..67d0efaf 100644 --- a/pages/api/l1-delegator/[address].tsx +++ b/pages/api/l1-delegator/[address].tsx @@ -2,13 +2,19 @@ import { getCacheControlHeader } from "@lib/api"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; import { controller } from "@lib/api/abis/main/Controller"; import { roundsManager } from "@lib/api/abis/main/RoundsManager"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { AddressSchema, L1DelegatorSchema } from "@lib/api/schemas"; import { L1Delegator, UnbondingLock } from "@lib/api/types/get-l1-delegator"; import { CHAIN_INFO, L1_CHAIN_ID, l1PublicClient } from "@lib/chains"; import { EMPTY_ADDRESS } from "@lib/utils"; import { keccak256, toUtf8Bytes } from "ethers/lib/utils"; import { NextApiRequest, NextApiResponse } from "next"; -import { Address, isAddress } from "viem"; +import { Address } from "viem"; const handler = async ( req: NextApiRequest, @@ -22,100 +28,114 @@ const handler = async ( const { address } = req.query; - if (!!address && !Array.isArray(address) && isAddress(address)) { - const bondingManagerHash = keccak256( - toUtf8Bytes("BondingManager") - ) as Address; - const bondingManagerAddress = await l1PublicClient.readContract({ - address: CHAIN_INFO[L1_CHAIN_ID].contracts.controller, - abi: controller, - functionName: "getContract", - args: [bondingManagerHash], - }); - - const roundsManagerHash = keccak256( - toUtf8Bytes("RoundsManager") - ) as Address; - const roundsManagerAddress = await l1PublicClient.readContract({ - address: CHAIN_INFO[L1_CHAIN_ID].contracts.controller, - abi: controller, - functionName: "getContract", - args: [roundsManagerHash], - }); + // AddressSchema handles undefined, arrays, and validates format + const addressResult = AddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; + + const validatedAddress = addressResult.data; + + const bondingManagerHash = keccak256( + toUtf8Bytes("BondingManager") + ) as Address; + const bondingManagerAddress = await l1PublicClient.readContract({ + address: CHAIN_INFO[L1_CHAIN_ID].contracts.controller, + abi: controller, + functionName: "getContract", + args: [bondingManagerHash], + }); + + const roundsManagerHash = keccak256( + toUtf8Bytes("RoundsManager") + ) as Address; + const roundsManagerAddress = await l1PublicClient.readContract({ + address: CHAIN_INFO[L1_CHAIN_ID].contracts.controller, + abi: controller, + functionName: "getContract", + args: [roundsManagerHash], + }); + + const currentRound = await l1PublicClient.readContract({ + address: roundsManagerAddress, + abi: roundsManager, + functionName: "currentRound", + }); + + const pendingStake = await l1PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "pendingStake", + args: [validatedAddress as Address, currentRound], + }); + const pendingFees = await l1PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "pendingFees", + args: [validatedAddress as Address, currentRound], + }); + const delegator = await l1PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "getDelegator", + args: [validatedAddress as Address], + }); + + let unbondingLockId = delegator[6]; + if (unbondingLockId > 0) { + unbondingLockId -= BigInt(1); + } - const currentRound = await l1PublicClient.readContract({ - address: roundsManagerAddress, - abi: roundsManager, - functionName: "currentRound", - }); + const unbondingLocks: UnbondingLock[] = []; - const pendingStake = await l1PublicClient.readContract({ - address: bondingManagerAddress, - abi: bondingManager, - functionName: "pendingStake", - args: [address as Address, currentRound], - }); - const pendingFees = await l1PublicClient.readContract({ + while (unbondingLockId >= 0) { + const lock = await l1PublicClient.readContract({ address: bondingManagerAddress, abi: bondingManager, - functionName: "pendingFees", - args: [address as Address, currentRound], + functionName: "getDelegatorUnbondingLock", + args: [validatedAddress as Address, unbondingLockId], }); - const delegator = await l1PublicClient.readContract({ - address: bondingManagerAddress, - abi: bondingManager, - functionName: "getDelegator", - args: [address as Address], - }); - - let unbondingLockId = delegator[6]; - if (unbondingLockId > 0) { - unbondingLockId -= BigInt(1); - } - - const unbondingLocks: UnbondingLock[] = []; - - while (unbondingLockId >= 0) { - const lock = await l1PublicClient.readContract({ - address: bondingManagerAddress, - abi: bondingManager, - functionName: "getDelegatorUnbondingLock", - args: [address as Address, unbondingLockId], - }); - unbondingLocks.push({ - id: Number(unbondingLockId), - amount: lock[0].toString(), - withdrawRound: lock[1].toString(), - }); - unbondingLockId -= BigInt(1); - } - - const delegateAddress = - delegator[2] === EMPTY_ADDRESS ? "" : delegator[2]; - - const transcoderStatus = await l1PublicClient.readContract({ - address: bondingManagerAddress, - abi: bondingManager, - functionName: "transcoderStatus", - args: [address as Address], + unbondingLocks.push({ + id: Number(unbondingLockId), + amount: lock[0].toString(), + withdrawRound: lock[1].toString(), }); - - const l1Delegator: L1Delegator = { - transcoderStatus: - transcoderStatus === 0 ? "not-registered" : "registered", - delegateAddress, - pendingStake: pendingStake.toString(), - pendingFees: pendingFees.toString(), - unbondingLocks, - activeLocks: unbondingLocks.filter( - (lock) => lock.withdrawRound != "0" - ), - }; - - return res.status(200).json(l1Delegator); - } else { - return badRequest(res, "Invalid address format"); + unbondingLockId -= BigInt(1); } + + const delegateAddress = + delegator[2] === EMPTY_ADDRESS ? "" : delegator[2]; + + const transcoderStatus = await l1PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "transcoderStatus", + args: [validatedAddress as Address], + }); + + const l1Delegator: L1Delegator = { + transcoderStatus: + transcoderStatus === 0 ? "not-registered" : "registered", + delegateAddress, + pendingStake: pendingStake.toString(), + pendingFees: pendingFees.toString(), + unbondingLocks, + activeLocks: unbondingLocks.filter((lock) => lock.withdrawRound != "0"), + }; + + // Validate output: L1 delegator response + const outputResult = L1DelegatorSchema.safeParse(l1Delegator); + const outputValidationError = validateOutput( + outputResult, + res, + "api/l1-delegator" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(l1Delegator); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/pending-stake/[address].tsx b/pages/api/pending-stake/[address].tsx index e5da3bef..4a0dff33 100644 --- a/pages/api/pending-stake/[address].tsx +++ b/pages/api/pending-stake/[address].tsx @@ -1,11 +1,16 @@ import { getCacheControlHeader, getCurrentRound } from "@lib/api"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; import { getBondingManagerAddress } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { AddressSchema, PendingFeesAndStakeSchema } from "@lib/api/schemas"; import { PendingFeesAndStake } from "@lib/api/types/get-pending-stake"; import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; -import { isAddress } from "viem"; const handler = async ( req: NextApiRequest, @@ -19,46 +24,62 @@ const handler = async ( const { address } = req.query; - if (!!address && !Array.isArray(address) && isAddress(address)) { - const bondingManagerAddress = await getBondingManagerAddress(); + // AddressSchema handles undefined, arrays, and validates format + const addressResult = AddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; - const { - data: { protocol }, - } = await getCurrentRound(); - const currentRoundString = protocol?.currentRound?.id; + const validatedAddress = addressResult.data; - if (!currentRoundString) { - throw new Error("No current round found"); - } - const currentRound = BigInt(currentRoundString); + const bondingManagerAddress = await getBondingManagerAddress(); - const [pendingStake, pendingFees] = await l2PublicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: bondingManagerAddress, - abi: bondingManager, - functionName: "pendingStake", - args: [address as `0x${string}`, currentRound], - }, - { - address: bondingManagerAddress, - abi: bondingManager, - functionName: "pendingFees", - args: [address as `0x${string}`, currentRound], - }, - ], - }); + const { + data: { protocol }, + } = await getCurrentRound(); + const currentRoundString = protocol?.currentRound?.id; - const roundInfo: PendingFeesAndStake = { - pendingStake: pendingStake.toString(), - pendingFees: pendingFees.toString(), - }; - - return res.status(200).json(roundInfo); - } else { - return badRequest(res, "Invalid address format"); + if (!currentRoundString) { + throw new Error("No current round found"); } + const currentRound = BigInt(currentRoundString); + + const [pendingStake, pendingFees] = await l2PublicClient.multicall({ + allowFailure: false, + contracts: [ + { + address: bondingManagerAddress, + abi: bondingManager, + functionName: "pendingStake", + args: [validatedAddress as `0x${string}`, currentRound], + }, + { + address: bondingManagerAddress, + abi: bondingManager, + functionName: "pendingFees", + args: [validatedAddress as `0x${string}`, currentRound], + }, + ], + }); + + const roundInfo: PendingFeesAndStake = { + pendingStake: pendingStake.toString(), + pendingFees: pendingFees.toString(), + }; + + // Validate output: pending fees and stake response + const outputResult = PendingFeesAndStakeSchema.safeParse(roundInfo); + const outputValidationError = validateOutput( + outputResult, + res, + "api/pending-stake" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(roundInfo); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/pipelines/index.tsx b/pages/api/pipelines/index.tsx index 8bc2abf8..b51c09ea 100644 --- a/pages/api/pipelines/index.tsx +++ b/pages/api/pipelines/index.tsx @@ -1,5 +1,11 @@ import { getCacheControlHeader } from "@lib/api"; -import { internalError, methodNotAllowed } from "@lib/api/errors"; +import { + externalApiError, + internalError, + methodNotAllowed, + validateInput, +} from "@lib/api/errors"; +import { AvailablePipelinesSchema, RegionSchema } from "@lib/api/schemas"; import { AvailablePipelines } from "@lib/api/types/get-available-pipelines"; import { fetchWithRetry } from "@lib/fetchWithRetry"; import { NextApiRequest, NextApiResponse } from "next"; @@ -15,16 +21,71 @@ const handler = async ( res.setHeader("Cache-Control", getCacheControlHeader("hour")); const { region } = req.query; + + // RegionSchema.optional() handles undefined, arrays, and validates non-empty strings + const regionResult = RegionSchema.optional().safeParse(region); + const inputValidationError = validateInput( + regionResult, + res, + "Invalid region format" + ); + if (inputValidationError) return inputValidationError; + + const validatedRegion = regionResult.data; + + if (!process.env.NEXT_PUBLIC_AI_METRICS_SERVER_URL) { + console.error("NEXT_PUBLIC_AI_METRICS_SERVER_URL is not set"); + return externalApiError( + res, + "AI metrics server", + "NEXT_PUBLIC_AI_METRICS_SERVER_URL environment variable is not configured" + ); + } + const url = `${ process.env.NEXT_PUBLIC_AI_METRICS_SERVER_URL - }/api/pipelines${region ? `?region=${region}` : ""}`; - const pipelinesResponse = await fetchWithRetry(url) - .then((res) => res.json()) - .catch(() => { - return { pipelines: [] }; - }); - const availablePipelines: AvailablePipelines = await pipelinesResponse; - return res.status(200).json(availablePipelines); + }/api/pipelines${validatedRegion ? `?region=${validatedRegion}` : ""}`; + + let pipelinesResponse: AvailablePipelines; + + try { + const response = await fetchWithRetry(url); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + console.error( + "Pipelines fetch error:", + response.status, + errorText, + `URL: ${url}` + ); + return externalApiError( + res, + "AI metrics server", + `Status ${response.status}: ${errorText}` + ); + } + + const responseData = await response.json(); + + // Validate external API response: pipelines response structure + const apiResult = AvailablePipelinesSchema.safeParse(responseData); + const validationError = validateInput( + apiResult, + res, + "Invalid response structure from AI metrics server" + ); + if (validationError) return validationError; + + pipelinesResponse = apiResult.data as AvailablePipelines; + } catch (err) { + console.error("[api/pipelines] Fetch error:", err); + // Fallback to empty pipelines on error (existing behavior) + // This is a known-good value, so no validation needed + pipelinesResponse = { pipelines: [] }; + } + + return res.status(200).json(pipelinesResponse); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/regions/index.ts b/pages/api/regions/index.ts index 1d94fe4d..d53d2da4 100644 --- a/pages/api/regions/index.ts +++ b/pages/api/regions/index.ts @@ -1,5 +1,11 @@ import { getCacheControlHeader } from "@lib/api"; -import { internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateExternalResponse, + validateOutput, +} from "@lib/api/errors"; +import { RegionObjectSchema, RegionsSchema } from "@lib/api/schemas"; import { Region, Regions } from "@lib/api/types/get-regions"; import { fetchWithRetry } from "@lib/fetchWithRetry"; import { NextApiRequest, NextApiResponse } from "next"; @@ -12,14 +18,24 @@ const METRICS_URL = [ /** * Fetch regions from a given URL. * @param url - The URL to fetch regions from. - * @returns Returns a promise that resolves to the regions or null if the fetch fails. + * @returns Returns a promise that resolves to the validated regions or null if the fetch fails. */ const fetchRegions = async ( url: string | undefined ): Promise => { if (!url) return null; const response = await fetchWithRetry(`${url}/api/regions`); - return response.ok ? response.json() : null; + if (!response.ok) return null; + + const responseData = await response.json(); + + // Validate external API response: regions response structure + const apiResult = RegionsSchema.safeParse(responseData); + return validateExternalResponse( + apiResult, + "api/regions", + `URL: ${url}/api/regions` + ); }; const handler = async ( @@ -33,15 +49,30 @@ const handler = async ( res.setHeader("Cache-Control", getCacheControlHeader("revalidate")); const regionsData = await Promise.all(METRICS_URL.map(fetchRegions)); + + // Validate and filter regions from external APIs + const validatedRegions = regionsData + .flatMap((data) => data?.regions || []) + .filter((region) => { + const regionResult = RegionObjectSchema.safeParse(region); + if (!regionResult.success) { + console.warn( + "[api/regions] Invalid region from external API:", + region, + regionResult.error.issues.map((e) => e.message).join(", ") + ); + return false; + } + return true; + }); + const mergedRegions: Regions = { regions: Array.from( new Map( - regionsData - .flatMap((data) => data?.regions || []) - .map((region) => [ - `${region.id}-${region.name}-${region.type}`, - region, - ]) + validatedRegions.map((region) => [ + `${region.id}-${region.name}-${region.type}`, + region, + ]) ).values() ), }; @@ -60,6 +91,15 @@ const handler = async ( } }); + // Validate output: regions response + const outputResult = RegionsSchema.safeParse(mergedRegions); + const outputValidationError = validateOutput( + outputResult, + res, + "api/regions" + ); + if (outputValidationError) return outputValidationError; + return res.status(200).json(mergedRegions); } diff --git a/pages/api/score/[address].tsx b/pages/api/score/[address].tsx index 572e5144..a0ea4724 100644 --- a/pages/api/score/[address].tsx +++ b/pages/api/score/[address].tsx @@ -1,10 +1,18 @@ import { getCacheControlHeader } from "@lib/api"; import { - badRequest, externalApiError, internalError, methodNotAllowed, + validateInput, + validateOutput, } from "@lib/api/errors"; +import { + AddressSchema, + MetricsResponseSchema, + PerformanceMetricsSchema, + PriceResponseSchema, + ScoreResponseSchema, +} from "@lib/api/schemas"; import { PerformanceMetrics, RegionalValues, @@ -13,7 +21,6 @@ import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "@lib/chains"; import { fetchWithRetry } from "@lib/fetchWithRetry"; import { avg, checkAddressEquality } from "@lib/utils"; import { NextApiRequest, NextApiResponse } from "next"; -import { isAddress } from "viem"; type Metric = { success_rate: number; @@ -60,117 +67,175 @@ const handler = async ( const method = req.method; if (method === "GET") { + res.setHeader("Cache-Control", getCacheControlHeader("hour")); + const { address } = req.query; - res.setHeader("Cache-Control", getCacheControlHeader("hour")); + // AddressSchema handles undefined, arrays, and validates format + const addressResult = AddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; + + if (!addressResult.success) { + return internalError(res, new Error("Address validation failed")); + } - if (!!address && !Array.isArray(address) && isAddress(address)) { - const transcoderId = address.toLowerCase(); - - const topScoreUrl = `${process.env.NEXT_PUBLIC_AI_METRICS_SERVER_URL}/api/top_ai_score?orchestrator=${transcoderId}`; - const metricsUrl = `${process.env.NEXT_PUBLIC_METRICS_SERVER_URL}/api/aggregated_stats?orchestrator=${transcoderId}`; - const pricingUrl = `${CHAIN_INFO[DEFAULT_CHAIN_ID].pricingUrl}?excludeUnavailable=False`; - - const [topScoreResponse, metricsResponse, priceResponse] = - await Promise.all([ - fetchWithRetry(topScoreUrl), - fetchWithRetry(metricsUrl), - fetchWithRetry(pricingUrl), - ]); - - if (!topScoreResponse.ok) { - const errorText = await topScoreResponse.text(); - console.error( - "Top AI score fetch error:", - topScoreResponse.status, - errorText - ); - return externalApiError(res, "AI metrics server"); - } - - if (!metricsResponse.ok) { - const errorText = await metricsResponse.text(); - console.error( - "Metrics fetch error:", - metricsResponse.status, - errorText - ); - return externalApiError(res, "metrics server"); - } - - if (!priceResponse.ok) { - const errorText = await priceResponse.text(); - console.error( - "Transcoder price fetch error:", - priceResponse.status, - errorText - ); - return externalApiError(res, "pricing server"); - } - - const topAIScore: ScoreResponse = await topScoreResponse.json(); - const metrics: MetricsResponse = await metricsResponse.json(); - const transcodersWithPrice: PriceResponse = await priceResponse.json(); - - const transcoderWithPrice = transcodersWithPrice.find((t) => - checkAddressEquality(t.Address, transcoderId) + const transcoderId = addressResult.data.toLowerCase(); + + const topScoreUrl = `${process.env.NEXT_PUBLIC_AI_METRICS_SERVER_URL}/api/top_ai_score?orchestrator=${transcoderId}`; + const metricsUrl = `${process.env.NEXT_PUBLIC_METRICS_SERVER_URL}/api/aggregated_stats?orchestrator=${transcoderId}`; + const pricingUrl = `${CHAIN_INFO[DEFAULT_CHAIN_ID].pricingUrl}?excludeUnavailable=False`; + + const [topScoreResponse, metricsResponse, priceResponse] = + await Promise.all([ + fetchWithRetry(topScoreUrl), + fetchWithRetry(metricsUrl), + fetchWithRetry(pricingUrl), + ]); + + if (!topScoreResponse.ok) { + const errorText = await topScoreResponse.text(); + console.error( + "Top AI score fetch error:", + topScoreResponse.status, + errorText, + `URL: ${topScoreUrl}` ); + return externalApiError( + res, + "AI metrics server", + `Status ${topScoreResponse.status}: ${errorText}` + ); + } - const uniqueRegions = (() => { - const keys = new Set(); - Object.values(metrics).forEach((metric) => { - if (metric) { - Object.keys(metric).forEach((key) => keys.add(key)); + if (!metricsResponse.ok) { + const errorText = await metricsResponse.text(); + console.error( + "Metrics fetch error:", + metricsResponse.status, + errorText, + `URL: ${metricsUrl}` + ); + return externalApiError( + res, + "metrics server", + `Status ${metricsResponse.status}: ${errorText}` + ); + } + + if (!priceResponse.ok) { + const errorText = await priceResponse.text(); + console.error( + "Transcoder price fetch error:", + priceResponse.status, + errorText + ); + return externalApiError(res, "pricing server"); + } + + const topScoreResult = ScoreResponseSchema.safeParse( + await topScoreResponse.json() + ); + const topScoreError = validateInput( + topScoreResult, + res, + "Invalid response from AI metrics server" + ); + if (topScoreError) return topScoreError; + const topAIScore: ScoreResponse = topScoreResult.data as ScoreResponse; + + const metricsResult = MetricsResponseSchema.safeParse( + await metricsResponse.json() + ); + const metricsError = validateInput( + metricsResult, + res, + "Invalid response from metrics server" + ); + if (metricsError) return metricsError; + const metrics: MetricsResponse = metricsResult.data as MetricsResponse; + + const priceResult = PriceResponseSchema.safeParse( + await priceResponse.json() + ); + const priceError = validateInput( + priceResult, + res, + "Invalid response from pricing server" + ); + if (priceError) return priceError; + const transcodersWithPrice: PriceResponse = + priceResult.data as unknown as PriceResponse; + + const transcoderWithPrice = transcodersWithPrice.find((t) => + checkAddressEquality(t.Address, transcoderId) + ); + + const uniqueRegions = (() => { + const keys = new Set(); + Object.values(metrics).forEach((metric) => { + if (metric) { + Object.keys(metric).forEach((key) => keys.add(key)); + } + }); + return Array.from(keys); + })(); + + const createMetricsObject = ( + metricKey: keyof Metric, + transcoderId: string, + metrics: MetricsResponse + ): RegionalValues => { + const metricsObject: RegionalValues = uniqueRegions.reduce( + (acc, metricsRegionKey) => { + const value = + metrics[transcoderId]?.[metricsRegionKey]?.[metricKey]; + if (value !== null && value !== undefined) { + acc[metricsRegionKey] = value * 100; } - }); - return Array.from(keys); - })(); - - const createMetricsObject = ( - metricKey: keyof Metric, - transcoderId: string, - metrics: MetricsResponse - ): RegionalValues => { - const metricsObject: RegionalValues = uniqueRegions.reduce( - (acc, metricsRegionKey) => { - const value = - metrics[transcoderId]?.[metricsRegionKey]?.[metricKey]; - if (value !== null && value !== undefined) { - acc[metricsRegionKey] = value * 100; - } - return acc; - }, - {} as RegionalValues - ); - - const globalValue = avg(metrics[transcoderId], metricKey) * 100; - - return { - ...metricsObject, - GLOBAL: globalValue, - }; - }; + return acc; + }, + {} as RegionalValues + ); - const combined: PerformanceMetrics = { - pricePerPixel: transcoderWithPrice?.PricePerPixel ?? 0, - successRates: createMetricsObject( - "success_rate", - transcoderId, - metrics - ), - roundTripScores: createMetricsObject( - "round_trip_score", - transcoderId, - metrics - ), - scores: createMetricsObject("score", transcoderId, metrics), - topAIScore, - }; + const globalValue = avg(metrics[transcoderId], metricKey) * 100; - return res.status(200).json(combined); - } else { - return badRequest(res, "Invalid address format"); - } + return { + ...metricsObject, + GLOBAL: globalValue, + }; + }; + + const combined: PerformanceMetrics = { + pricePerPixel: transcoderWithPrice?.PricePerPixel ?? 0, + successRates: createMetricsObject( + "success_rate", + transcoderId, + metrics + ), + roundTripScores: createMetricsObject( + "round_trip_score", + transcoderId, + metrics + ), + scores: createMetricsObject("score", transcoderId, metrics), + topAIScore, + }; + + // Validate output: performance metrics response + const outputResult = PerformanceMetricsSchema.safeParse(combined); + const outputValidationError = validateOutput( + outputResult, + res, + "api/score" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(combined); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/score/index.tsx b/pages/api/score/index.tsx index d17ecca6..6e82e97a 100644 --- a/pages/api/score/index.tsx +++ b/pages/api/score/index.tsx @@ -1,5 +1,17 @@ import { getCacheControlHeader } from "@lib/api"; -import { internalError, methodNotAllowed } from "@lib/api/errors"; +import { + externalApiError, + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { + AllPerformanceMetricsSchema, + MetricsResponseSchema, + PipelineQuerySchema, + PriceResponseSchema, +} from "@lib/api/schemas"; import { AllPerformanceMetrics, RegionalValues, @@ -19,7 +31,15 @@ const handler = async ( const method = req.method; if (method === "GET") { - const { pipeline, model } = req.query; + const queryResult = PipelineQuerySchema.safeParse(req.query); + const inputValidationError = validateInput( + queryResult, + res, + "Invalid query parameters" + ); + if (inputValidationError) return inputValidationError; + + const { pipeline, model } = queryResult.data || {}; res.setHeader("Cache-Control", getCacheControlHeader("hour")); @@ -33,13 +53,52 @@ const handler = async ( ? `?pipeline=${pipeline}${model ? `&model=${model}` : ""}` : "" }` - ).then((res) => res.json()); + ); + + if (!metricsResponse.ok) { + const errorText = await metricsResponse.text(); + console.error( + "Metrics fetch error:", + metricsResponse.status, + errorText + ); + return externalApiError(res, "metrics server", "Fetch failed"); + } + + const metricsJson = await metricsResponse.json(); + + const metricsResult = MetricsResponseSchema.safeParse(metricsJson); + const metricsError = validateInput( + metricsResult, + res, + "Invalid response from metrics server" + ); + if (metricsError) return metricsError; + const metrics: MetricsResponse = metricsResult.data as MetricsResponse; - const metrics: MetricsResponse = await metricsResponse; const response = await fetchWithRetry( CHAIN_INFO[DEFAULT_CHAIN_ID].pricingUrl ); - const transcodersWithPrice: PriceResponse = await response.json(); + if (!response.ok) { + const errorText = await response.text(); + console.error( + "Pricing fetch error:", + response.status, + errorText, + `URL: ${CHAIN_INFO[DEFAULT_CHAIN_ID].pricingUrl}` + ); + return externalApiError(res, "pricing server", "Fetch failed"); + } + + const priceResult = PriceResponseSchema.safeParse(await response.json()); + const priceError = validateInput( + priceResult, + res, + "Invalid response from pricing server" + ); + if (priceError) return priceError; + const transcodersWithPrice: PriceResponse = + priceResult.data as unknown as PriceResponse; const allTranscoderIds = Object.keys(metrics); const uniqueRegions = (() => { @@ -103,6 +162,13 @@ const handler = async ( {} ); + const outputValidationError = validateOutput( + AllPerformanceMetricsSchema.safeParse(combined), + res, + "api/score" + ); + if (outputValidationError) return outputValidationError; + return res.status(200).json(combined); } diff --git a/pages/api/totalTokenSupply.tsx b/pages/api/totalTokenSupply.tsx index 6dfb143c..5170ee94 100644 --- a/pages/api/totalTokenSupply.tsx +++ b/pages/api/totalTokenSupply.tsx @@ -3,7 +3,12 @@ import { externalApiError, internalError, methodNotAllowed, + validateOutput, } from "@lib/api/errors"; +import { + SubgraphTotalSupplyResponseSchema, + TotalTokenSupplyOutputSchema, +} from "@lib/api/schemas/total-token-supply"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "@lib/chains"; import { fetchWithRetry } from "@lib/fetchWithRetry"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -41,10 +46,36 @@ const totalTokenSupply = async (_req: NextApiRequest, res: NextApiResponse) => { res.setHeader("Cache-Control", getCacheControlHeader("day")); + const jsonResponse = await response.json(); + + const subgraphValidation = + SubgraphTotalSupplyResponseSchema.safeParse(jsonResponse); + + if (!subgraphValidation.success) { + return externalApiError( + res, + "subgraph", + "Invalid response structure from subgraph" + ); + } + const { data: { protocol }, - } = await response.json(); - return res.status(200).json(Number(protocol.totalSupply)); + } = subgraphValidation.data; + + const result = Number(protocol.totalSupply); + + if ( + validateOutput( + TotalTokenSupplyOutputSchema.safeParse(result), + res, + "totalTokenSupply" + ) + ) { + return; + } + + return res.status(200).json(result); } return methodNotAllowed(res, method ?? "unknown", ["GET"]); diff --git a/pages/api/treasury/proposal/[proposalId]/state.tsx b/pages/api/treasury/proposal/[proposalId]/state.tsx index 819e627f..8b4b8bc2 100644 --- a/pages/api/treasury/proposal/[proposalId]/state.tsx +++ b/pages/api/treasury/proposal/[proposalId]/state.tsx @@ -5,7 +5,13 @@ import { getBondingVotesAddress, getLivepeerGovernorAddress, } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { ProposalIdSchema, ProposalStateSchema } from "@lib/api/schemas"; import { ProposalState } from "@lib/api/types/get-treasury-proposal"; import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; @@ -26,94 +32,121 @@ const handler = async ( res: NextApiResponse ) => { try { - const { method } = req; - if (method !== "GET") { - return methodNotAllowed(res, method ?? "unknown", ["GET"]); - } - res.setHeader("Cache-Control", getCacheControlHeader("second")); + const method = req.method; - const proposalId = req.query.proposalId?.toString(); - if (!proposalId) { - return badRequest(res, "Missing proposalId"); - } + if (method === "GET") { + res.setHeader("Cache-Control", getCacheControlHeader("second")); - const livepeerGovernorAddress = await getLivepeerGovernorAddress(); - const bondingVotesAddress = await getBondingVotesAddress(); - if (!livepeerGovernorAddress || !bondingVotesAddress) { - throw new Error("Unsupported chain"); - } + const proposalId = req.query.proposalId?.toString(); - const now = await l2PublicClient.readContract({ - address: livepeerGovernorAddress, - abi: livepeerGovernor, - functionName: "clock", - }); - let snapshot = await l2PublicClient.readContract({ - address: livepeerGovernorAddress, - abi: livepeerGovernor, - functionName: "proposalSnapshot", - args: [BigInt(proposalId)], - }); - // we can only fetch quorum up to past round now - 1 - if (snapshot >= now) { - snapshot = BigInt(now - 1); - } + // ProposalIdSchema validates format (numeric string) + const proposalIdResult = ProposalIdSchema.safeParse(proposalId); + const inputValidationError = validateInput( + proposalIdResult, + res, + "Invalid proposalId format" + ); + if (inputValidationError) return inputValidationError; - const totalVoteSupply = await l2PublicClient - .readContract({ - address: bondingVotesAddress, - abi: bondingVotes, - functionName: "getPastTotalSupply", - args: [snapshot], - }) - .then((bn) => bn.toString()); - - const votes = await l2PublicClient - .readContract({ + // TypeScript needs explicit check for type narrowing + if (!proposalIdResult.success) { + return internalError(res, new Error("Unexpected validation error")); + } + + // After the success check, TypeScript knows data is defined + const validatedProposalId: string = proposalIdResult.data; + + const livepeerGovernorAddress = await getLivepeerGovernorAddress(); + const bondingVotesAddress = await getBondingVotesAddress(); + if (!livepeerGovernorAddress || !bondingVotesAddress) { + throw new Error("Unsupported chain"); + } + + const now = await l2PublicClient.readContract({ address: livepeerGovernorAddress, abi: livepeerGovernor, - functionName: "proposalVotes", - args: [BigInt(proposalId)], - }) - .then((votes) => votes.map((bn) => bn.toString())); - - const state = await l2PublicClient.readContract({ - address: livepeerGovernorAddress, - abi: livepeerGovernor, - functionName: "state", - args: [BigInt(proposalId)], - }); - - const quorum = await l2PublicClient - .readContract({ + functionName: "clock", + }); + let snapshot = await l2PublicClient.readContract({ address: livepeerGovernorAddress, abi: livepeerGovernor, - functionName: "quorum", - args: [snapshot], - }) - .then((bn) => bn.toString()); - - // This is the only function not in the original OZ Governor interface - const quota = await l2PublicClient - .readContract({ + functionName: "proposalSnapshot", + args: [BigInt(validatedProposalId)], + }); + // we can only fetch quorum up to past round now - 1 + if (snapshot >= now) { + snapshot = BigInt(now - 1); + } + + const totalVoteSupply = await l2PublicClient + .readContract({ + address: bondingVotesAddress, + abi: bondingVotes, + functionName: "getPastTotalSupply", + args: [snapshot], + }) + .then((bn) => bn.toString()); + + const votes = await l2PublicClient + .readContract({ + address: livepeerGovernorAddress, + abi: livepeerGovernor, + functionName: "proposalVotes", + args: [BigInt(validatedProposalId)], + }) + .then((votes) => votes.map((bn) => bn.toString())); + + const state = await l2PublicClient.readContract({ address: livepeerGovernorAddress, abi: livepeerGovernor, - functionName: "quota", - }) - .then((bn) => bn.toString()); - - return res.status(200).json({ - id: proposalId, - state: ProposalStateEnum[state] ?? "Unknown", - quota, - quorum, - totalVoteSupply, - votes: { - against: votes[0], - for: votes[1], - abstain: votes[2], - }, - }); + functionName: "state", + args: [BigInt(validatedProposalId)], + }); + + const quorum = await l2PublicClient + .readContract({ + address: livepeerGovernorAddress, + abi: livepeerGovernor, + functionName: "quorum", + args: [snapshot], + }) + .then((bn) => bn.toString()); + + // This is the only function not in the original OZ Governor interface + const quota = await l2PublicClient + .readContract({ + address: livepeerGovernorAddress, + abi: livepeerGovernor, + functionName: "quota", + }) + .then((bn) => bn.toString()); + + const proposalState: ProposalState = { + id: validatedProposalId, + state: ProposalStateEnum[state] ?? "Unknown", + quota, + quorum, + totalVoteSupply, + votes: { + against: votes[0], + for: votes[1], + abstain: votes[2], + }, + }; + + // Validate output: proposal state response + const outputResult = ProposalStateSchema.safeParse(proposalState); + const outputValidationError = validateOutput( + outputResult, + res, + "api/treasury/proposal/[proposalId]/state" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(proposalState); + } + + return methodNotAllowed(res, method ?? "unknown", ["GET"]); } catch (err) { console.error("state api error", err); return internalError(res, err); diff --git a/pages/api/treasury/proposal/[proposalId]/votes/[address].tsx b/pages/api/treasury/proposal/[proposalId]/votes/[address].tsx index dac26555..8780883e 100644 --- a/pages/api/treasury/proposal/[proposalId]/votes/[address].tsx +++ b/pages/api/treasury/proposal/[proposalId]/votes/[address].tsx @@ -5,88 +5,135 @@ import { getBondingVotesAddress, getLivepeerGovernorAddress, } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { + AddressSchema, + ProposalIdSchema, + ProposalVotingPowerSchema, +} from "@lib/api/schemas"; import { ProposalVotingPower } from "@lib/api/types/get-treasury-proposal"; import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; -import { Address, isAddress } from "viem"; +import { Address } from "viem"; const handler = async ( req: NextApiRequest, res: NextApiResponse ) => { try { - const { method } = req; - if (method !== "GET") { - return methodNotAllowed(res, method ?? "unknown", ["GET"]); - } - res.setHeader("Cache-Control", getCacheControlHeader("second")); + const method = req.method; - const proposalId = req.query.proposalId?.toString(); - if (!proposalId) { - return badRequest(res, "Missing proposalId"); - } - const address = req.query.address?.toString(); - if (!(!!address && isAddress(address))) { - return badRequest(res, "Invalid address format"); - } + if (method === "GET") { + res.setHeader("Cache-Control", getCacheControlHeader("second")); - const livepeerGovernorAddress = await getLivepeerGovernorAddress(); - const bondingVotesAddress = await getBondingVotesAddress(); - if (!livepeerGovernorAddress || !bondingVotesAddress) { - throw new Error("Unsupported chain"); - } + const proposalId = req.query.proposalId?.toString(); + const address = req.query.address?.toString(); - const now = await l2PublicClient.readContract({ - address: livepeerGovernorAddress, - abi: livepeerGovernor, - functionName: "clock", - }); - let snapshot = await l2PublicClient.readContract({ - address: livepeerGovernorAddress, - abi: livepeerGovernor, - functionName: "proposalSnapshot", - args: [BigInt(proposalId)], - }); - if (snapshot > now) { - snapshot = BigInt(now); - } + // ProposalIdSchema validates format (numeric string) + const proposalIdResult = ProposalIdSchema.safeParse(proposalId); + const proposalIdValidationError = validateInput( + proposalIdResult, + res, + "Invalid proposalId format" + ); + if (proposalIdValidationError) return proposalIdValidationError; + + // AddressSchema validates format + const addressResult = AddressSchema.safeParse(address); + const addressValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (addressValidationError) return addressValidationError; + + // TypeScript needs explicit checks for type narrowing + if (!proposalIdResult.success) { + return internalError(res, new Error("Unexpected validation error")); + } + if (!addressResult.success) { + return internalError(res, new Error("Unexpected validation error")); + } + + // After the success checks, TypeScript knows data is defined + const validatedProposalId: string = proposalIdResult.data; + const validatedAddress: string = addressResult.data; + + const livepeerGovernorAddress = await getLivepeerGovernorAddress(); + const bondingVotesAddress = await getBondingVotesAddress(); + if (!livepeerGovernorAddress || !bondingVotesAddress) { + throw new Error("Unsupported chain"); + } - const getVotes = async (address: Address) => { - const votesProm = l2PublicClient - .readContract({ - address: bondingVotesAddress, - abi: bondingVotes, - ...(snapshot < now - ? { functionName: "getPastVotes", args: [address, snapshot] } - : { functionName: "getVotes", args: [address] }), - }) - .then((bn: bigint) => bn.toString()); - const hasVotedProm = l2PublicClient.readContract({ + const now = await l2PublicClient.readContract({ address: livepeerGovernorAddress, abi: livepeerGovernor, - functionName: "hasVoted", - args: [BigInt(proposalId), address], + functionName: "clock", }); + let snapshot = await l2PublicClient.readContract({ + address: livepeerGovernorAddress, + abi: livepeerGovernor, + functionName: "proposalSnapshot", + args: [BigInt(validatedProposalId)], + }); + if (snapshot > now) { + snapshot = BigInt(now); + } + + const getVotes = async (address: Address) => { + const votesProm = l2PublicClient + .readContract({ + address: bondingVotesAddress, + abi: bondingVotes, + ...(snapshot < now + ? { functionName: "getPastVotes", args: [address, snapshot] } + : { functionName: "getVotes", args: [address] }), + }) + .then((bn: bigint) => bn.toString()); + const hasVotedProm = l2PublicClient.readContract({ + address: livepeerGovernorAddress, + abi: livepeerGovernor, + functionName: "hasVoted", + args: [BigInt(validatedProposalId), address], + }); + + const [votes, hasVoted] = await Promise.all([votesProm, hasVotedProm]); + return { address, votes, hasVoted }; + }; + + const delegateAddress = await l2PublicClient.readContract({ + address: bondingVotesAddress, + abi: bondingVotes, + functionName: "delegatedAt", + args: [validatedAddress as Address, snapshot], + }); + + const votingPower: ProposalVotingPower = { + self: await getVotes(validatedAddress as Address), + delegate: + delegateAddress.toLowerCase() === validatedAddress.toLowerCase() + ? undefined + : await getVotes(delegateAddress), + }; + + // Validate output: proposal voting power response + const outputResult = ProposalVotingPowerSchema.safeParse(votingPower); + const outputValidationError = validateOutput( + outputResult, + res, + "api/treasury/proposal/[proposalId]/votes/[address]" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(votingPower); + } - const [votes, hasVoted] = await Promise.all([votesProm, hasVotedProm]); - return { address, votes, hasVoted }; - }; - - const delegateAddress = await l2PublicClient.readContract({ - address: bondingVotesAddress, - abi: bondingVotes, - functionName: "delegatedAt", - args: [address, snapshot], - }); - - return res.status(200).json({ - self: await getVotes(address), - delegate: - delegateAddress.toLowerCase() === address.toLowerCase() - ? undefined - : await getVotes(delegateAddress), - }); + return methodNotAllowed(res, method ?? "unknown", ["GET"]); } catch (err) { return internalError(res, err); } diff --git a/pages/api/treasury/votes/[address]/index.tsx b/pages/api/treasury/votes/[address]/index.tsx index 13857814..f1487f47 100644 --- a/pages/api/treasury/votes/[address]/index.tsx +++ b/pages/api/treasury/votes/[address]/index.tsx @@ -5,11 +5,17 @@ import { getBondingVotesAddress, getLivepeerGovernorAddress, } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { AddressSchema, VotingPowerSchema } from "@lib/api/schemas"; import { VotingPower } from "@lib/api/types/get-treasury-proposal"; import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; -import { Address, isAddress } from "viem"; +import { Address } from "viem"; const handler = async ( req: NextApiRequest, @@ -23,10 +29,20 @@ const handler = async ( res.setHeader("Cache-Control", getCacheControlHeader("second")); const address = req.query.address?.toString(); - if (!(!!address && isAddress(address))) { - return badRequest(res, "Invalid address format"); + const addressResult = AddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; + + if (!addressResult.success) { + return internalError(res, new Error("Unexpected validation error")); } + const validatedAddress = addressResult.data as Address; + const livepeerGovernorAddress = await getLivepeerGovernorAddress(); const bondingVotesAddress = await getBondingVotesAddress(); if (!livepeerGovernorAddress || !bondingVotesAddress) { @@ -46,34 +62,44 @@ const handler = async ( functionName: "clock", }); - const getVotes = async (address: Address) => { + const getVotes = async (addr: Address) => { const votes = await l2PublicClient .readContract({ address: bondingVotesAddress, abi: bondingVotes, functionName: "getPastVotes", - args: [address, BigInt(currentRound - 1)], + args: [addr, BigInt(currentRound - 1)], }) .then((bn) => bn.toString()); - return { address, votes }; + return { address: addr, votes }; }; const delegateAddress = await l2PublicClient.readContract({ address: bondingVotesAddress, abi: bondingVotes, functionName: "delegates", - args: [address], + args: [validatedAddress], }); - return res.status(200).json({ + const votingPower: VotingPower = { proposalThreshold, - self: await getVotes(address), + self: await getVotes(validatedAddress), delegate: - delegateAddress.toLowerCase() === address.toLowerCase() + delegateAddress.toLowerCase() === validatedAddress.toLowerCase() ? undefined : await getVotes(delegateAddress), - }); + }; + + const outputResult = VotingPowerSchema.safeParse(votingPower); + const outputValidationError = validateOutput( + outputResult, + res, + "api/treasury/votes/[address]" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(votingPower); } catch (err) { return internalError(res, err); } diff --git a/pages/api/treasury/votes/[address]/registered.tsx b/pages/api/treasury/votes/[address]/registered.tsx index bbe57c5c..619411cb 100644 --- a/pages/api/treasury/votes/[address]/registered.tsx +++ b/pages/api/treasury/votes/[address]/registered.tsx @@ -5,11 +5,17 @@ import { getBondingManagerAddress, getBondingVotesAddress, } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { + internalError, + methodNotAllowed, + validateInput, + validateOutput, +} from "@lib/api/errors"; +import { AddressSchema, RegisteredToVoteSchema } from "@lib/api/schemas"; import { RegisteredToVote } from "@lib/api/types/get-treasury-proposal"; import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; -import { isAddress } from "viem"; +import { Address } from "viem"; const handler = async ( req: NextApiRequest, @@ -22,10 +28,20 @@ const handler = async ( } const address = req.query.address?.toString(); - if (!(!!address && isAddress(address))) { - return badRequest(res, "Invalid address format"); + const addressResult = AddressSchema.safeParse(address); + const inputValidationError = validateInput( + addressResult, + res, + "Invalid address format" + ); + if (inputValidationError) return inputValidationError; + + if (!addressResult.success) { + return internalError(res, new Error("Unexpected validation error")); } + const validatedAddress = addressResult.data as Address; + const bondingManagerAddress = await getBondingManagerAddress(); const bondingVotesAddress = await getBondingVotesAddress(); if (!bondingManagerAddress || !bondingVotesAddress) { @@ -37,31 +53,41 @@ const handler = async ( address: bondingManagerAddress, abi: bondingManager, functionName: "getDelegator", - args: [address], + args: [validatedAddress], } ); const isBonded = bondedAmount > 0; if (!isBonded) { - res.setHeader("Cache-Control", getCacheControlHeader("week")); - // we dont need to checkpoint unbonded addresses, so we consider them registered - return res.status(200).json({ + const response: RegisteredToVote = { registered: true, delegate: { address: delegateAddress, registered: true, }, - }); + }; + + const outputResult = RegisteredToVoteSchema.safeParse(response); + const outputValidationError = validateOutput( + outputResult, + res, + "api/treasury/votes/[address]/registered (unbonded)" + ); + if (outputValidationError) return outputValidationError; + + res.setHeader("Cache-Control", getCacheControlHeader("week")); + // we dont need to checkpoint unbonded addresses, so we consider them registered + return res.status(200).json(response); } const registered = await l2PublicClient.readContract({ address: bondingVotesAddress, abi: bondingVotes, functionName: "hasCheckpoint", - args: [address], + args: [validatedAddress], }); const delegateRegistered = - delegateAddress === address + delegateAddress === validatedAddress ? registered : await l2PublicClient.readContract({ address: bondingVotesAddress, @@ -77,13 +103,23 @@ const handler = async ( ) ); - return res.status(200).json({ + const response: RegisteredToVote = { registered, delegate: { address: delegateAddress, registered: delegateRegistered, }, - }); + }; + + const outputResult = RegisteredToVoteSchema.safeParse(response); + const outputValidationError = validateOutput( + outputResult, + res, + "api/treasury/votes/[address]/registered" + ); + if (outputValidationError) return outputValidationError; + + return res.status(200).json(response); } catch (err) { return internalError(res, err); } diff --git a/pages/api/upload-ipfs.tsx b/pages/api/upload-ipfs.tsx index 56b25c15..fce49616 100644 --- a/pages/api/upload-ipfs.tsx +++ b/pages/api/upload-ipfs.tsx @@ -2,7 +2,14 @@ import { externalApiError, internalError, methodNotAllowed, + validateInput, + validateOutput, } from "@lib/api/errors"; +import { + PinataPinResponseSchema, + UploadIpfsInputSchema, + UploadIpfsOutputSchema, +} from "@lib/api/schemas/upload-ipfs"; import { AddIpfs } from "@lib/api/types/add-ipfs"; import { NextApiRequest, NextApiResponse } from "next"; @@ -14,6 +21,13 @@ const handler = async ( const method = req.method; if (method === "POST") { + const inputValidation = UploadIpfsInputSchema.safeParse(req.body); + + // Explicit check required for TypeScript type narrowing + if (!inputValidation.success) { + return validateInput(inputValidation, res, "Invalid JSON body"); + } + const fetchResult = await fetch( `https://api.pinata.cloud/pinning/pinJSONToIPFS`, { @@ -22,7 +36,7 @@ const handler = async ( "Content-Type": "application/json", Authorization: `Bearer ${process.env.PINATA_JWT}`, }, - body: JSON.stringify(req.body), + body: JSON.stringify(inputValidation.data), } ); @@ -32,7 +46,29 @@ const handler = async ( const result = await fetchResult.json(); - return res.status(200).json({ hash: result.IpfsHash }); + const pinataValidation = PinataPinResponseSchema.safeParse(result); + + if (!pinataValidation.success) { + return externalApiError( + res, + "Pinata IPFS", + "Invalid response from Pinata" + ); + } + + const response = { hash: pinataValidation.data.IpfsHash }; + + if ( + validateOutput( + UploadIpfsOutputSchema.safeParse(response), + res, + "upload-ipfs" + ) + ) { + return; + } + + return res.status(200).json(response); } return methodNotAllowed(res, method ?? "unknown", ["POST"]); diff --git a/pages/api/usage.tsx b/pages/api/usage.tsx index 34c5498d..f3a240e6 100644 --- a/pages/api/usage.tsx +++ b/pages/api/usage.tsx @@ -3,7 +3,9 @@ import { externalApiError, internalError, methodNotAllowed, + validateOutput, } from "@lib/api/errors"; +import { DayDataSchema, UsageOutputSchema } from "@lib/api/schemas/usage"; import { DayData, HomeChartData, @@ -14,42 +16,6 @@ import { fetchWithRetry } from "@lib/fetchWithRetry"; import { getPercentChange } from "@lib/utils"; import { historicalDayData } from "data/historical-usage"; import { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - -// Parse schema zod for DayData -const DayDataSchema = z.array( - z.object({ - dateS: z.number(), - volumeEth: z - .number() - .nullish() - .transform((val) => val ?? 0), - volumeUsd: z - .number() - .nullish() - .transform((val) => val ?? 0), - feeDerivedMinutes: z - .number() - .nullish() - .transform((val) => val ?? 0), - participationRate: z - .number() - .nullish() - .transform((val) => val ?? 0), - inflation: z - .number() - .nullish() - .transform((val) => val ?? 0), - activeTranscoderCount: z - .number() - .nullish() - .transform((val) => val ?? 0), - delegatorsCount: z - .number() - .nullish() - .transform((val) => val ?? 0), - }) -); const chartDataHandler = async ( req: NextApiRequest, @@ -111,6 +77,14 @@ const chartDataHandler = async ( .sort((a, b) => (a.dateS > b.dateS ? 1 : -1)) .filter((s) => s.activeTranscoderCount); + // Ensure we have enough data for calculations + if (sortedDays.length < 2) { + return internalError( + res, + new Error("Insufficient daily data for calculations") + ); + } + let startIndexWeekly = -1; let currentWeek = -1; @@ -136,6 +110,14 @@ const chartDataHandler = async ( day.feeDerivedMinutes; } + // Ensure we have enough weekly data for calculations + if (weeklyData.length < 3) { + return internalError( + res, + new Error("Insufficient weekly data for calculations") + ); + } + // const currentWeekData = weeklyData[weeklyData.length - 1]; const oneWeekBackData = weeklyData[weeklyData.length - 2]; const twoWeekBackData = weeklyData[weeklyData.length - 3]; @@ -228,6 +210,10 @@ const chartDataHandler = async ( data.dayData = data.dayData.slice(0, -1); } + if (validateOutput(UsageOutputSchema.safeParse(data), res, "usage")) { + return; + } + res.setHeader("Cache-Control", getCacheControlHeader("day")); return res.status(200).json(data);