From df4e22f8d852434437bbd28af024f44cbe506410 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 29 Sep 2026 06:17:12 -0400 Subject: [PATCH] fix(protocol): validate constants response with Zod schema (#1979) --- src/lib/schemas/apiContracts.ts | 3 + src/utils/__tests__/protocol.test.ts | 299 +++++++++++++++++++++++++++ src/utils/protocol.ts | 93 +++++++++ src/utils/tests/protocol.test.ts | 299 +++++++++++++++++++++++++++ 4 files changed, 694 insertions(+) create mode 100644 src/utils/__tests__/protocol.test.ts create mode 100644 src/utils/protocol.ts create mode 100644 src/utils/tests/protocol.test.ts diff --git a/src/lib/schemas/apiContracts.ts b/src/lib/schemas/apiContracts.ts index 509ef8fe..ac54fe31 100644 --- a/src/lib/schemas/apiContracts.ts +++ b/src/lib/schemas/apiContracts.ts @@ -177,6 +177,9 @@ export const ProtocolConstantsSchema = z.object({ export const ProtocolConstantsResponseSchema = OkBodySchema(ProtocolConstantsSchema); +export type ProtocolConstants = z.infer; +export type ProtocolConstantsResponse = z.infer; + // ─── Early-exit request validation ────────────────────────────────────────── /** diff --git a/src/utils/__tests__/protocol.test.ts b/src/utils/__tests__/protocol.test.ts new file mode 100644 index 00000000..f8e1b861 --- /dev/null +++ b/src/utils/__tests__/protocol.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchProtocolConstants, + getEarlyExitGracePeriodDays, + type ProtocolConstants, +} from '../protocol'; + +const protocolConstantsFixture: ProtocolConstants = { + protocolVersion: '1.0.0', + network: 'testnet', + fees: { + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }, + penalties: [ + { + type: 'early_exit', + earlyExitPenaltyPercent: 15, + description: 'Penalty charged when a commitment exits before maturity.', + }, + { + type: 'default', + earlyExitPenaltyPercent: 30, + description: 'Penalty charged when commitment terms are not met.', + }, + ], + commitmentLimits: { + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + earlyExitGracePeriodDays: 7, + }, + cachedAt: '2026-06-27T08:00:00.000Z', +}; + +describe('fetchProtocolConstants', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('requests protocol constants and resolves enveloped response payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + success: true, + data: protocolConstantsFixture, + }), + }); + + const constants: ProtocolConstants = await fetchProtocolConstants(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + expect(constants).toEqual(protocolConstantsFixture); + expect(constants.fees).toEqual({ + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }); + expect(constants.penalties).toEqual(protocolConstantsFixture.penalties); + expect(constants.commitmentLimits).toEqual({ + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + earlyExitGracePeriodDays: 7, + }); + }); + + it('requests protocol constants and resolves direct un-enveloped response payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(protocolConstantsFixture), + }); + + const constants: ProtocolConstants = await fetchProtocolConstants(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + expect(constants).toEqual(protocolConstantsFixture); + }); + + it('requests protocol constants with custom endpoint when provided', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + success: true, + data: protocolConstantsFixture, + }), + }); + + const constants: ProtocolConstants = await fetchProtocolConstants('/api/custom/constants'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/custom/constants'); + expect(constants).toEqual(protocolConstantsFixture); + }); + + it('throws an error containing statusText when the constants request is not OK', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: vi.fn(), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + 'Failed to fetch protocol constants: Internal Server Error', + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + }); + + it('throws a validation error when commitmentLimits.earlyExitGracePeriodDays is missing in direct payload', async () => { + const malformedFixture = { + protocolVersion: '1.0.0', + network: 'testnet', + fees: { + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }, + penalties: [ + { + type: 'early_exit', + earlyExitPenaltyPercent: 15, + description: 'Penalty charged when a commitment exits before maturity.', + }, + ], + commitmentLimits: { + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + }, + cachedAt: '2026-06-27T08:00:00.000Z', + }; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(malformedFixture), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*earlyExitGracePeriodDays/i, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + }); + + it('throws a validation error when commitmentLimits.earlyExitGracePeriodDays is missing in enveloped payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + success: true, + data: { + protocolVersion: '1.0.0', + network: 'testnet', + fees: { + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }, + penalties: [ + { + type: 'early_exit', + earlyExitPenaltyPercent: 15, + description: 'Penalty charged when a commitment exits before maturity.', + }, + ], + commitmentLimits: { + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + }, + cachedAt: '2026-06-27T08:00:00.000Z', + }, + }), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*earlyExitGracePeriodDays/i, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + }); + + it('throws a validation error when fee fields have invalid types', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + ...protocolConstantsFixture, + fees: { + networkBaseFeeStroops: -10, + platformFeePercent: 'invalid-fee', + }, + }), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*fees/i, + ); + }); + + it('throws a validation error when cachedAt is not a valid datetime string', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + ...protocolConstantsFixture, + cachedAt: 'not-a-datetime', + }), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*cachedAt/i, + ); + }); + + it('throws a validation error when payload is not an object', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce('unexpected-string-body'), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload/i, + ); + }); +}); + +describe('getEarlyExitGracePeriodDays', () => { + it('returns the normalized grace-period constant', () => { + expect(getEarlyExitGracePeriodDays(protocolConstantsFixture)).toBe(7); + }); + + it('floors positive fractional grace-period days', () => { + const fractionalFixture: ProtocolConstants = { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: 5.8, + }, + }; + expect(getEarlyExitGracePeriodDays(fractionalFixture)).toBe(5); + }); + + it('falls back to 0 when constants are null or undefined', () => { + expect(getEarlyExitGracePeriodDays(null)).toBe(0); + expect(getEarlyExitGracePeriodDays(undefined)).toBe(0); + }); + + it('falls back to 0 when commitmentLimits is missing or earlyExitGracePeriodDays is not finite', () => { + const invalidValues = [ + { + ...protocolConstantsFixture, + commitmentLimits: undefined as unknown as ProtocolConstants['commitmentLimits'], + }, + { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: NaN, + }, + }, + { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: -5, + }, + }, + { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: '7' as unknown as number, + }, + }, + ]; + + for (const invalid of invalidValues) { + expect(getEarlyExitGracePeriodDays(invalid)).toBe(0); + } + }); +}); diff --git a/src/utils/protocol.ts b/src/utils/protocol.ts new file mode 100644 index 00000000..b791720b --- /dev/null +++ b/src/utils/protocol.ts @@ -0,0 +1,93 @@ +import { + ProtocolConstantsResponseSchema, + ProtocolConstantsSchema, +} from '@/lib/schemas/apiContracts'; + +export interface PenaltyTier { + type: string; + earlyExitPenaltyPercent: number; + description: string; +} + +export interface FeeConstants { + networkBaseFeeStroops: number; + platformFeePercent: number; +} + +export interface CommitmentLimits { + minAmountXlm: number; + maxAmountXlm: number; + minDurationDays: number; + maxDurationDays: number; + maxLossPercentCeiling: number; + earlyExitGracePeriodDays: number; +} + +export interface ProtocolConstants { + protocolVersion: string; + network: string; + fees: FeeConstants; + penalties: PenaltyTier[]; + commitmentLimits: CommitmentLimits; + cachedAt: string; +} + +export { ProtocolConstantsSchema, ProtocolConstantsResponseSchema }; + +/** + * Fetches and validates protocol constants from the API endpoint. + * + * @param endpoint Optional custom endpoint URL for protocol constants. + * @returns Validated protocol constants domain object. + * @throws Error when HTTP request is not OK or response body fails schema validation. + */ +export async function fetchProtocolConstants( + endpoint = '/api/protocol/constants', +): Promise { + const response = await fetch(endpoint); + + if (!response.ok) { + throw new Error(`Failed to fetch protocol constants: ${response.statusText}`); + } + + const json: unknown = await response.json(); + + const envelopedParsed = ProtocolConstantsResponseSchema.safeParse(json); + if (envelopedParsed.success) { + return envelopedParsed.data.data; + } + + const directParsed = ProtocolConstantsSchema.safeParse(json); + if (directParsed.success) { + return directParsed.data; + } + + const isEnvelopedShape = + typeof json === 'object' && json !== null && 'data' in json && 'success' in json; + + const relevantError = isEnvelopedShape ? envelopedParsed.error : directParsed.error; + + const issues = relevantError?.issues + .map((issue) => `${issue.path.join('.') || 'root'}: ${issue.message}`) + .join('; '); + + throw new Error(`Failed to validate protocol constants response payload: ${issues}`); +} + +/** + * Extracts and normalizes the early exit grace period duration in days. + * + * @param constants Protocol constants object or null/undefined if unavailable. + * @returns Non-negative integer representing the grace period days, defaulting to 0. + */ +export function getEarlyExitGracePeriodDays( + constants: ProtocolConstants | null | undefined, +): number { + const value = constants?.commitmentLimits?.earlyExitGracePeriodDays; + + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} diff --git a/src/utils/tests/protocol.test.ts b/src/utils/tests/protocol.test.ts new file mode 100644 index 00000000..f8e1b861 --- /dev/null +++ b/src/utils/tests/protocol.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchProtocolConstants, + getEarlyExitGracePeriodDays, + type ProtocolConstants, +} from '../protocol'; + +const protocolConstantsFixture: ProtocolConstants = { + protocolVersion: '1.0.0', + network: 'testnet', + fees: { + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }, + penalties: [ + { + type: 'early_exit', + earlyExitPenaltyPercent: 15, + description: 'Penalty charged when a commitment exits before maturity.', + }, + { + type: 'default', + earlyExitPenaltyPercent: 30, + description: 'Penalty charged when commitment terms are not met.', + }, + ], + commitmentLimits: { + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + earlyExitGracePeriodDays: 7, + }, + cachedAt: '2026-06-27T08:00:00.000Z', +}; + +describe('fetchProtocolConstants', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('requests protocol constants and resolves enveloped response payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + success: true, + data: protocolConstantsFixture, + }), + }); + + const constants: ProtocolConstants = await fetchProtocolConstants(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + expect(constants).toEqual(protocolConstantsFixture); + expect(constants.fees).toEqual({ + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }); + expect(constants.penalties).toEqual(protocolConstantsFixture.penalties); + expect(constants.commitmentLimits).toEqual({ + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + earlyExitGracePeriodDays: 7, + }); + }); + + it('requests protocol constants and resolves direct un-enveloped response payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(protocolConstantsFixture), + }); + + const constants: ProtocolConstants = await fetchProtocolConstants(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + expect(constants).toEqual(protocolConstantsFixture); + }); + + it('requests protocol constants with custom endpoint when provided', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + success: true, + data: protocolConstantsFixture, + }), + }); + + const constants: ProtocolConstants = await fetchProtocolConstants('/api/custom/constants'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/custom/constants'); + expect(constants).toEqual(protocolConstantsFixture); + }); + + it('throws an error containing statusText when the constants request is not OK', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: vi.fn(), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + 'Failed to fetch protocol constants: Internal Server Error', + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + }); + + it('throws a validation error when commitmentLimits.earlyExitGracePeriodDays is missing in direct payload', async () => { + const malformedFixture = { + protocolVersion: '1.0.0', + network: 'testnet', + fees: { + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }, + penalties: [ + { + type: 'early_exit', + earlyExitPenaltyPercent: 15, + description: 'Penalty charged when a commitment exits before maturity.', + }, + ], + commitmentLimits: { + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + }, + cachedAt: '2026-06-27T08:00:00.000Z', + }; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(malformedFixture), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*earlyExitGracePeriodDays/i, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + }); + + it('throws a validation error when commitmentLimits.earlyExitGracePeriodDays is missing in enveloped payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + success: true, + data: { + protocolVersion: '1.0.0', + network: 'testnet', + fees: { + networkBaseFeeStroops: 100, + platformFeePercent: 2.5, + }, + penalties: [ + { + type: 'early_exit', + earlyExitPenaltyPercent: 15, + description: 'Penalty charged when a commitment exits before maturity.', + }, + ], + commitmentLimits: { + minAmountXlm: 10, + maxAmountXlm: 100_000, + minDurationDays: 7, + maxDurationDays: 365, + maxLossPercentCeiling: 50, + }, + cachedAt: '2026-06-27T08:00:00.000Z', + }, + }), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*earlyExitGracePeriodDays/i, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/protocol/constants'); + }); + + it('throws a validation error when fee fields have invalid types', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + ...protocolConstantsFixture, + fees: { + networkBaseFeeStroops: -10, + platformFeePercent: 'invalid-fee', + }, + }), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*fees/i, + ); + }); + + it('throws a validation error when cachedAt is not a valid datetime string', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ + ...protocolConstantsFixture, + cachedAt: 'not-a-datetime', + }), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload.*cachedAt/i, + ); + }); + + it('throws a validation error when payload is not an object', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce('unexpected-string-body'), + }); + + await expect(fetchProtocolConstants()).rejects.toThrow( + /Failed to validate protocol constants response payload/i, + ); + }); +}); + +describe('getEarlyExitGracePeriodDays', () => { + it('returns the normalized grace-period constant', () => { + expect(getEarlyExitGracePeriodDays(protocolConstantsFixture)).toBe(7); + }); + + it('floors positive fractional grace-period days', () => { + const fractionalFixture: ProtocolConstants = { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: 5.8, + }, + }; + expect(getEarlyExitGracePeriodDays(fractionalFixture)).toBe(5); + }); + + it('falls back to 0 when constants are null or undefined', () => { + expect(getEarlyExitGracePeriodDays(null)).toBe(0); + expect(getEarlyExitGracePeriodDays(undefined)).toBe(0); + }); + + it('falls back to 0 when commitmentLimits is missing or earlyExitGracePeriodDays is not finite', () => { + const invalidValues = [ + { + ...protocolConstantsFixture, + commitmentLimits: undefined as unknown as ProtocolConstants['commitmentLimits'], + }, + { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: NaN, + }, + }, + { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: -5, + }, + }, + { + ...protocolConstantsFixture, + commitmentLimits: { + ...protocolConstantsFixture.commitmentLimits, + earlyExitGracePeriodDays: '7' as unknown as number, + }, + }, + ]; + + for (const invalid of invalidValues) { + expect(getEarlyExitGracePeriodDays(invalid)).toBe(0); + } + }); +});