UNPKG

@anuragchvn-blip/mandatekit

Version:

Production-ready Web3 autopay SDK for crypto-based recurring payments using EIP-712 mandates

257 lines 9.08 kB
/** * Validation utilities for MandateKit SDK * Provides comprehensive validation for mandates and related data * @module utils/validation */ import { ValidationError, ExpiredMandateError, MandateNotYetValidError, MaxPaymentsReachedError, } from '../errors/index.js'; import { validateCadence, isPaymentDue } from './cadence.js'; import { isValidAddress, isValidSignature } from './crypto.js'; /** * Validates a mandate structure for completeness and correctness * * @param mandate - Mandate to validate * @throws {ValidationError} If any field is invalid * * @example * ```typescript * try { * validateMandate(mandate); * console.log('Mandate is valid'); * } catch (error) { * console.error('Validation failed:', error.message); * } * ``` */ export function validateMandate(mandate) { // Validate subscriber address if (!mandate.subscriber || !isValidAddress(mandate.subscriber)) { throw new ValidationError('Invalid subscriber address', 'subscriber'); } // Validate token address if (!mandate.token || !isValidAddress(mandate.token)) { throw new ValidationError('Invalid token address', 'token'); } // Validate recipient address if (!mandate.recipient || !isValidAddress(mandate.recipient)) { throw new ValidationError('Invalid recipient address', 'recipient'); } // Validate amount const amount = BigInt(mandate.amount); if (amount <= 0n) { throw new ValidationError('Amount must be greater than zero', 'amount'); } // Validate cadence validateCadence(mandate.cadence); // Validate timestamps if (!Number.isInteger(mandate.validAfter) || mandate.validAfter < 0) { throw new ValidationError('validAfter must be a valid unix timestamp', 'validAfter'); } if (!Number.isInteger(mandate.validBefore) || mandate.validBefore < 0) { throw new ValidationError('validBefore must be a valid unix timestamp', 'validBefore'); } if (mandate.validBefore <= mandate.validAfter) { throw new ValidationError('validBefore must be after validAfter', 'validBefore'); } // Validate nonce const nonce = BigInt(mandate.nonce); if (nonce < 0n) { throw new ValidationError('Nonce must be non-negative', 'nonce'); } // Validate maxPayments if provided if (mandate.maxPayments !== undefined) { if (!Number.isInteger(mandate.maxPayments) || mandate.maxPayments < 1) { throw new ValidationError('maxPayments must be a positive integer', 'maxPayments'); } } // Validate metadata if provided if (mandate.metadata !== undefined) { if (typeof mandate.metadata !== 'string') { throw new ValidationError('metadata must be a string', 'metadata'); } if (mandate.metadata.length > 1000) { throw new ValidationError('metadata exceeds maximum length of 1000 characters', 'metadata'); } } } /** * Validates a signed mandate including signature format * * @param signedMandate - Signed mandate to validate * @throws {ValidationError} If mandate or signature is invalid */ export function validateSignedMandate(signedMandate) { // First validate the base mandate validateMandate(signedMandate); // Validate signature format if (!signedMandate.signature || !isValidSignature(signedMandate.signature)) { throw new ValidationError('Invalid signature format', 'signature'); } // Validate signedAt timestamp if (!Number.isInteger(signedMandate.signedAt) || signedMandate.signedAt < 0) { throw new ValidationError('signedAt must be a valid unix timestamp', 'signedAt'); } // Validate chainId if (!Number.isInteger(signedMandate.chainId) || signedMandate.chainId < 1) { throw new ValidationError('chainId must be a positive integer', 'chainId'); } } /** * Checks if a mandate is currently active based on timestamps * * @param mandate - Mandate to check * @param currentTime - Current unix timestamp (defaults to now) * @returns True if mandate is within valid time window */ export function isMandateActive(mandate, currentTime = Math.floor(Date.now() / 1000)) { return currentTime >= mandate.validAfter && currentTime < mandate.validBefore; } /** * Validates that a mandate is active, throwing descriptive errors if not * * @param mandate - Mandate to validate * @param currentTime - Current unix timestamp (defaults to now) * @throws {MandateNotYetValidError} If before validAfter * @throws {ExpiredMandateError} If after validBefore */ export function validateMandateActive(mandate, currentTime = Math.floor(Date.now() / 1000)) { if (currentTime < mandate.validAfter) { throw new MandateNotYetValidError(mandate.validAfter, currentTime); } if (currentTime >= mandate.validBefore) { throw new ExpiredMandateError(mandate.validBefore, currentTime); } } /** * Validates execution count against maxPayments limit * * @param mandate - Mandate with potential maxPayments limit * @param executionCount - Current number of executed payments * @throws {MaxPaymentsReachedError} If limit exceeded */ export function validateExecutionCount(mandate, executionCount) { if (mandate.maxPayments && executionCount >= mandate.maxPayments) { throw new MaxPaymentsReachedError(mandate.maxPayments, executionCount); } } /** * Performs comprehensive validation of a mandate for execution * Combines multiple validation checks * * @param mandate - Mandate to validate * @param executionCount - Current number of executed payments * @param lastExecutionTime - Timestamp of last execution (if any) * @param currentTime - Current unix timestamp (defaults to now) * @returns Verification result with detailed status * * @example * ```typescript * const result = verifyMandateForExecution( * mandate, * executionCount, * lastExecutionTime * ); * if (result.isValid && result.isExecutionDue) { * // Safe to execute payment * } * ``` */ export function verifyMandateForExecution(mandate, executionCount, lastExecutionTime, currentTime = Math.floor(Date.now() / 1000)) { const errors = []; let isValid = true; let isActive = true; let isWithinTimeRange = true; let isExecutionDue = true; try { validateMandate(mandate); } catch (error) { isValid = false; errors.push(`Validation failed: ${error.message}`); } try { validateMandateActive(mandate, currentTime); } catch (error) { isActive = false; isWithinTimeRange = false; errors.push(`Mandate inactive: ${error.message}`); } try { validateExecutionCount(mandate, executionCount); } catch (error) { isActive = false; errors.push(`Max payments reached: ${error.message}`); } // Check if payment is due (only if there was a previous execution) if (lastExecutionTime) { const due = isPaymentDue(lastExecutionTime, mandate.cadence, currentTime); if (!due) { isExecutionDue = false; errors.push('Payment is not yet due according to cadence'); } } return { isValid: isValid && errors.length === 0, isActive, isWithinTimeRange, isExecutionDue, errors, }; } /** * Checks if two addresses are equal (case-insensitive) * * @param address1 - First address * @param address2 - Second address * @returns True if addresses are equal */ export function addressesEqual(address1, address2) { return address1.toLowerCase() === address2.toLowerCase(); } /** * Validates that an amount is sufficient for a payment * * @param available - Available balance * @param required - Required amount * @param tokenSymbol - Token symbol for error message * @throws {ValidationError} If balance is insufficient */ export function validateSufficientBalance(available, required, tokenSymbol = 'tokens') { if (available < required) { throw new ValidationError(`Insufficient balance: have ${available.toString()} ${tokenSymbol}, need ${required.toString()} ${tokenSymbol}`); } } /** * Sanitizes metadata string to prevent injection attacks * * @param metadata - Metadata string to sanitize * @returns Sanitized metadata */ export function sanitizeMetadata(metadata) { // Remove control characters and limit length return metadata .split('') .filter(char => { const code = char.charCodeAt(0); return code > 0x1F && code !== 0x7F; }) .join('') .slice(0, 1000); // Limit length } /** * Parses amount string to bigint, handling various formats * * @param amount - Amount as string or bigint * @returns Parsed bigint amount * @throws {ValidationError} If amount cannot be parsed */ export function parseAmount(amount) { try { return BigInt(amount); } catch (error) { throw new ValidationError(`Invalid amount format: ${amount}`, 'amount'); } } //# sourceMappingURL=validation.js.map