UNPKG

@anuragchvn-blip/mandatekit

Version:

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

260 lines 8.36 kB
/** * Cryptographic utilities for MandateKit SDK * Provides secure hashing, signature recovery, and EIP-712 helpers * @module utils/crypto */ import { keccak256, encodeAbiParameters, parseAbiParameters, recoverAddress, hashTypedData, } from 'viem'; import { ValidationError } from '../errors/index.js'; /** * EIP-712 type definitions for Mandate * Order matters for proper hashing */ export const MANDATE_TYPES = { Mandate: [ { name: 'subscriber', type: 'address' }, { name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'cadenceInterval', type: 'uint8' }, { name: 'cadenceCount', type: 'uint256' }, { name: 'recipient', type: 'address' }, { name: 'validAfter', type: 'uint256' }, { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'maxPayments', type: 'uint256' }, { name: 'metadata', type: 'string' }, ], }; /** * Converts cadence interval string to uint8 for EIP-712 * @param interval - Cadence interval type * @returns Numeric representation (0=daily, 1=weekly, 2=monthly, 3=yearly, 4=custom) */ export function cadenceIntervalToUint8(interval) { const mapping = { daily: 0, weekly: 1, monthly: 2, yearly: 3, custom: 4, }; return mapping[interval]; } /** * Converts uint8 to cadence interval string * @param value - Numeric interval value * @returns Cadence interval type */ export function uint8ToCadenceInterval(value) { const mapping = ['daily', 'weekly', 'monthly', 'yearly', 'custom']; if (value < 0 || value >= mapping.length) { throw new ValidationError(`Invalid cadence interval value: ${value}`); } return mapping[value]; } /** * Prepares EIP-712 typed data for mandate signing * * @param mandate - Mandate to prepare for signing * @param domain - EIP-712 domain separator configuration * @returns Properly formatted typed data structure * * @example * ```typescript * const typedData = prepareMandateTypedData(mandate, { * name: 'MandateRegistry', * version: '1', * chainId: 1, * verifyingContract: '0x...', * }); * ``` */ export function prepareMandateTypedData(mandate, domain) { // Cast to any to work around type definition limitations const typedData = { domain, types: MANDATE_TYPES, primaryType: 'Mandate', message: { subscriber: mandate.subscriber, token: mandate.token, amount: BigInt(mandate.amount), cadenceInterval: cadenceIntervalToUint8(mandate.cadence.interval), cadenceCount: BigInt(mandate.cadence.count), recipient: mandate.recipient, validAfter: mandate.validAfter, validBefore: mandate.validBefore, nonce: BigInt(mandate.nonce), maxPayments: mandate.maxPayments ?? 0, metadata: mandate.metadata ?? '', }, }; return typedData; } /** * Computes the EIP-712 hash of a mandate * This is the message hash that should be signed * * @param mandate - Mandate to hash * @param domain - EIP-712 domain configuration * @returns Keccak256 hash of the structured data * * @example * ```typescript * const hash = hashMandate(mandate, domain); * console.log('Mandate hash:', hash); * ``` */ export function hashMandate(mandate, domain) { const typedData = prepareMandateTypedData(mandate, domain); return hashTypedData(typedData); } /** * Recovers the signer address from a mandate signature * Used for signature verification and authentication * * @param mandate - Original mandate that was signed * @param signature - EIP-712 signature to verify * @param domain - EIP-712 domain used for signing * @returns Recovered Ethereum address of the signer * * @example * ```typescript * const signer = await recoverMandateSigner(mandate, signature, domain); * if (signer.toLowerCase() === mandate.subscriber.toLowerCase()) { * console.log('Valid signature from subscriber'); * } * ``` */ export async function recoverMandateSigner(mandate, signature, domain) { const hash = hashMandate(mandate, domain); return recoverAddress({ hash, signature }); } /** * Generates a unique mandate ID from its hash * Useful for indexing and tracking mandates * * @param mandate - Mandate to generate ID for * @param domain - EIP-712 domain configuration * @returns Unique mandate identifier * * @example * ```typescript * const mandateId = generateMandateId(mandate, domain); * // Store in database with this ID * ``` */ export function generateMandateId(mandate, domain) { return hashMandate(mandate, domain); } /** * Computes a payment record hash for verification * Used to prove a payment was executed correctly * * @param mandateHash - Hash of the original mandate * @param executionCount - Sequential payment number * @param executedAt - Timestamp of execution * @param amountPaid - Actual amount transferred * @returns Hash of the payment record * * @example * ```typescript * const recordHash = hashPaymentRecord( * mandateHash, * 1, * Math.floor(Date.now() / 1000), * parseEther('10') * ); * ``` */ export function hashPaymentRecord(mandateHash, executionCount, executedAt, amountPaid) { const encoded = encodeAbiParameters(parseAbiParameters('bytes32, uint256, uint256, uint256'), [ mandateHash, BigInt(executionCount), BigInt(executedAt), amountPaid, ]); return keccak256(encoded); } /** * Validates that a signature is properly formatted * @param signature - Signature to validate * @returns True if signature format is valid */ export function isValidSignature(signature) { // Must be a valid hex string with 0x prefix if (!signature.startsWith('0x')) return false; // Standard ECDSA signature is 65 bytes (130 hex chars + 2 for 0x) if (signature.length !== 132) return false; // Must be valid hex return /^0x[0-9a-fA-F]{130}$/.test(signature); } /** * Securely compares two signatures for equality * Uses constant-time comparison to prevent timing attacks * * @param sig1 - First signature * @param sig2 - Second signature * @returns True if signatures are equal */ export function compareSignatures(sig1, sig2) { if (sig1.length !== sig2.length) return false; let result = 0; for (let i = 0; i < sig1.length; i++) { result |= sig1.charCodeAt(i) ^ sig2.charCodeAt(i); } return result === 0; } /** * Generates a unique nonce based on timestamp and random data * Useful for creating fresh nonces for new mandates * * @returns Cryptographically secure random nonce * * @example * ```typescript * const nonce = generateNonce(); * ``` */ export function generateNonce() { // Combine timestamp with random bytes for uniqueness const timestamp = BigInt(Date.now()); // Use Web Crypto API (works in browser and Node.js 15+) const cryptoObj = typeof globalThis !== 'undefined' && globalThis.crypto ? globalThis.crypto : typeof global !== 'undefined' && global.crypto ? global.crypto : null; if (!cryptoObj || !cryptoObj.getRandomValues) { // Fallback to Math.random() if crypto is not available const randomValue = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)); return timestamp * BigInt(2 ** 32) + randomValue; } const randomBytes = cryptoObj.getRandomValues(new Uint8Array(16)); const randomHex = Array.from(randomBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); return timestamp * BigInt(2 ** 128) + BigInt('0x' + randomHex); } /** * Validates an Ethereum address format * @param address - Address to validate * @returns True if address is valid */ export function isValidAddress(address) { return /^0x[0-9a-fA-F]{40}$/.test(address); } /** * Normalizes an address to checksummed format * @param address - Address to normalize * @returns Checksummed address */ export function normalizeAddress(address) { if (!isValidAddress(address)) { throw new ValidationError(`Invalid Ethereum address: ${address}`, 'address'); } return address.toLowerCase(); } //# sourceMappingURL=crypto.js.map