/** * Gas-Free Validation Utilities * * Zod schemas for validating gas-free requests and responses * * @module features/gas-free/utils/validation */ import { z } from 'zod'; import type { FeeQuote, FeeQuoteRequest, MiningUTXO, MiningChangeUTXO, } from '../types/FeeQuote'; import type { GasFreeResult, SubmitResult, SubmitPSBTRequest, VerificationResult, VerifyTransferRequest, } from '../types/GasFreeResult'; import { GasFreeTransferStatus } from '../types/GasFreeResult'; import { GasFreeError } from '../errors/GasFreeError'; /** * Schema for Mining UTXO */ export const MiningUTXOSchema = z.object({ txid: z .string() .length(64, 'Transaction ID must be 64 characters') .regex(/^[0-9a-f]+$/, 'Transaction ID must be hexadecimal'), vout: z.number().int().min(0, 'vout must be non-negative'), value: z.number().int().positive('Value must be positive'), script_pubkey: z .string() .min(1, 'Script pubkey must not be empty') .regex(/^[0-9a-f]+$/, 'Script pubkey must be hexadecimal'), }) satisfies z.ZodType; /** * Schema for Mining Change UTXO */ export const MiningChangeUTXOSchema = z.object({ address: z.string().min(1, 'Address must not be empty'), value: z.number().int().positive('Value must be positive'), }) satisfies z.ZodType; /** * Schema for Fee Quote */ export const FeeQuoteSchema = z.object({ quoteId: z.string().min(1, 'Quote ID must not be empty'), userId: z.string().min(1, 'User ID must not be empty'), assetId: z.string().optional(), miningFeeSats: z .number() .int() .nonnegative('Mining fee must be non-negative'), feeRateSatPerVByte: z.number().nonnegative('Fee rate must be non-negative'), serviceFeePercentage: z .number() .nonnegative('Service fee percentage must be non-negative'), serviceFeeAmount: z .number() .nonnegative('Service fee amount must be non-negative'), assetType: z.string().optional(), witnessUtxoFundingSats: z .number() .int() .nonnegative('Witness UTXO funding sats must be non-negative'), miningUTXO: MiningUTXOSchema, miningChangeUTXO: MiningChangeUTXOSchema.optional(), serviceFeeInvoice: z.string().min(1, 'Service fee invoice must not be empty'), serviceFeeRecipientId: z .string() .min(1, 'Service fee recipient ID must not be empty'), status: z.enum(['pending', 'accepted', 'expired', 'completed', 'failed']), expiresAt: z.string().min(1, 'Expires at must not be empty'), createdAt: z.string().min(1, 'Created at must not be empty'), }) satisfies z.ZodType; /** * Schema for Fee Quote Request */ export const FeeQuoteRequestSchema = z.object({ userId: z.string().min(1, 'User ID cannot be empty'), assetId: z .string() .min(1, 'Asset ID cannot be empty') .regex(/^rgb:/, 'Asset ID must start with rgb: prefix'), numInputs: z.number().int().positive('Number of inputs must be a positive integer'), numOutputs: z .number() .int() .positive('Number of outputs must be a positive integer'), recipientInvoice: z .string() .min(1, 'Recipient invoice cannot be empty') .regex(/^rgb1?:/, 'Recipient invoice must start with rgb: or rgb1:'), transferAmount: z .number() .positive('Transfer amount must be positive') .finite('Transfer amount must be finite'), }) satisfies z.ZodType; /** * Schema for Submit PSBT Request */ export const SubmitPSBTRequestSchema = z.object({ quoteId: z.string().uuid('Quote ID must be a valid UUID'), psbtBase64: z.string().min(1, 'PSBT cannot be empty'), consignmentBase64: z.string().min(1, 'Consignment data is required (base64-encoded)'), }) satisfies z.ZodType; /** * Schema for Verify Transfer Request */ export const VerifyTransferRequestSchema = z.object({ quoteId: z.string().min(1, 'Quote ID must not be empty'), transferSuccess: z.boolean(), signedPsbtBase64: z.string().optional(), txid: z.string().optional(), failureReason: z.string().optional(), }) satisfies z.ZodType; /** * Schema for Gas-Free Transfer Status */ export const GasFreeTransferStatusSchema = z.nativeEnum(GasFreeTransferStatus); /** * Schema for Gas-Free Result */ export const GasFreeResultSchema = z.object({ status: GasFreeTransferStatusSchema, txid: z.string().optional(), consignment: z.string().optional(), quoteId: z.string().min(1, 'Quote ID must not be empty'), totalFeeSats: z.number().int().nonnegative('Total fee must be non-negative'), miningFeeSats: z .number() .int() .nonnegative('Mining fee must be non-negative'), serviceFeeSats: z .number() .int() .nonnegative('Service fee must be non-negative'), rgbAmount: z.string().min(1, 'RGB amount must not be empty'), assetId: z.string().min(1, 'Asset ID must not be empty'), recipient: z.string().min(1, 'Recipient must not be empty'), initiatedAt: z .number() .int() .positive('Initiated timestamp must be positive'), completedAt: z.number().int().positive().optional(), error: z.string().optional(), metadata: z.record(z.unknown()).optional(), }) satisfies z.ZodType; /** * Schema for Submit Result */ export const SubmitResultSchema = z.object({ quoteId: z.string().min(1, 'Quote ID must not be empty'), signedPsbtBase64: z.string().min(1, 'Signed PSBT base64 must not be empty'), transactionId: z.string().min(1, 'Transaction ID must not be empty'), estimatedTxSize: z .number() .int() .positive('Estimated tx size must be positive'), miningUtxoTxid: z .string() .length(64, 'Mining UTXO txid must be 64 characters'), miningUtxoVout: z .number() .int() .min(0, 'Mining UTXO vout must be non-negative'), signedAt: z.string().min(1, 'Signed at must not be empty'), }) satisfies z.ZodType; /** * Schema for Verification Result */ export const VerificationResultSchema = z.object({ quoteId: z.string().min(1, 'Quote ID must not be empty'), transactionId: z.string(), // Can be empty string if failed/errored status: z.enum(['verified', 'pending_verification', 'failed', 'errored']), message: z.string().min(1, 'Message must not be empty'), inMempool: z.boolean(), verifiedAt: z.string().min(1, 'Verified at must not be empty'), }) satisfies z.ZodType; /** * Validate a fee quote * * @param data - Data to validate * @returns Validated fee quote * @throws {GasFreeError} If validation fails */ export function validateFeeQuote(data: unknown): FeeQuote { try { return FeeQuoteSchema.parse(data); } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map( (e) => `${e.path.join('.')}: ${e.message}` ); throw GasFreeError.invalidRequest( `Fee quote validation failed:\n- ${errors.join('\n- ')}`, { validationErrors: errors } ); } throw error; } } /** * Validate a fee quote request * * @param data - Data to validate * @returns Validated fee quote request * @throws {GasFreeError} If validation fails */ export function validateFeeQuoteRequest(data: unknown): FeeQuoteRequest { try { return FeeQuoteRequestSchema.parse(data); } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map( (e) => `${e.path.join('.')}: ${e.message}` ); throw GasFreeError.invalidRequest( `Fee quote request validation failed:\n- ${errors.join('\n- ')}`, { validationErrors: errors } ); } throw error; } } /** * Validate a submit result * * @param data - Data to validate * @returns Validated submit result * @throws {GasFreeError} If validation fails */ export function validateSubmitResult(data: unknown): SubmitResult { try { return SubmitResultSchema.parse(data); } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map( (e) => `${e.path.join('.')}: ${e.message}` ); throw GasFreeError.invalidRequest( `Submit result validation failed:\n- ${errors.join('\n- ')}`, { validationErrors: errors } ); } throw error; } } /** * Validate a verification result * * @param data - Data to validate * @returns Validated verification result * @throws {GasFreeError} If validation fails */ export function validateVerificationResult(data: unknown): VerificationResult { try { return VerificationResultSchema.parse(data); } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map( (e) => `${e.path.join('.')}: ${e.message}` ); throw GasFreeError.invalidRequest( `Verification result validation failed:\n- ${errors.join('\n- ')}`, { validationErrors: errors } ); } throw error; } } /** * Validate a PSBT string * * @param psbt - PSBT to validate * @returns True if valid * @throws {GasFreeError} If validation fails */ export function validatePSBT(psbt: string): boolean { if (!psbt || typeof psbt !== 'string') { throw GasFreeError.invalidRequest('PSBT must be a non-empty string'); } // PSBT should be base64 encoded and start with "cHNi" (base64 for "psbt") if (!psbt.startsWith('cHNi')) { throw GasFreeError.invalidRequest( 'PSBT must be base64 encoded and start with "cHNi"' ); } // Check if valid base64 const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; if (!base64Regex.test(psbt)) { throw GasFreeError.invalidRequest('PSBT must be valid base64'); } return true; } /** * Validate RGB amount string * * @param amount - Amount to validate * @returns True if valid * @throws {GasFreeError} If validation fails */ export function validateRGBAmount(amount: string): boolean { if (!amount || typeof amount !== 'string') { throw GasFreeError.invalidRequest('RGB amount must be a non-empty string'); } try { const amountBigInt = BigInt(amount); if (amountBigInt <= 0n) { throw GasFreeError.invalidRequest('RGB amount must be positive'); } } catch { throw GasFreeError.invalidRequest( 'RGB amount must be a valid number string' ); } return true; }