UNPKG

@anuragchvn-blip/mandatekit

Version:

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

363 lines 13.1 kB
/** * Relayer module - Automated execution engine for pull-based payments * @module relayer */ import { calculateNextExecution, isPaymentDue, validateExecutionTiming } from '../utils/cadence.js'; import { verifyMandateForExecution, validateMandateActive } from '../utils/validation.js'; import { generateMandateId } from '../utils/crypto.js'; import { RelayerError, ContractError } from '../errors/index.js'; const store = { mandates: new Map(), }; /** * Creates a Relayer client for automated mandate execution * * @param config - Relayer configuration * @param publicClient - Viem public client for reading blockchain state * @param walletClient - Viem wallet client for executing transactions * @param domain - EIP-712 domain configuration * @returns RelayerClient instance * * @example * ```typescript * import { createRelayerClient } from '@mandatekit/sdk/relayer'; * import { createPublicClient, createWalletClient, http } from 'viem'; * import { privateKeyToAccount } from 'viem/accounts'; * import { mainnet } from 'viem/chains'; * * const publicClient = createPublicClient({ * chain: mainnet, * transport: http(), * }); * * const account = privateKeyToAccount('0x...'); * const walletClient = createWalletClient({ * account, * chain: mainnet, * transport: http(), * }); * * const relayer = createRelayerClient( * { * rpcUrl: 'https://eth-mainnet.alchemyapi.io/v2/...', * chainId: 1, * registryAddress: '0x...', * account: account.address, * }, * publicClient, * walletClient, * { * name: 'MandateRegistry', * version: '1', * chainId: 1, * verifyingContract: '0x...', * } * ); * * // Schedule a mandate * const plan = await relayer.scheduleMandate(signedMandate); * * // Execute payment when due * const record = await relayer.executePullPayment(mandateId); * ``` */ export function createRelayerClient(config, _publicClient, _walletClient, domain) { const { registryAddress } = config; /** * Schedule a mandate for automated execution */ const scheduleMandate = async (signedMandate) => { // Validate mandate is active try { validateMandateActive(signedMandate); } catch (error) { throw new RelayerError(`Cannot schedule inactive mandate: ${error.message}`); } // Generate mandate ID const mandateId = generateMandateId(signedMandate, domain); // Store mandate store.mandates.set(mandateId, { mandate: signedMandate, executionCount: 0, isActive: true, }); // Calculate first execution time const nextExecution = signedMandate.validAfter; return { mandate: signedMandate, nextExecution, executionCount: 0, isActive: true, estimatedGas: 200000n, // Estimated gas for pull payment }; }; /** * Execute a pull payment for a mandate */ const executePullPayment = async (mandateId) => { // Get mandate from storage const stored = store.mandates.get(mandateId); if (!stored) { throw new RelayerError(`Mandate ${mandateId} not found`); } const { mandate, executionCount, lastExecutionTime } = stored; // Verify mandate can be executed const currentTime = Math.floor(Date.now() / 1000); if (lastExecutionTime) { validateExecutionTiming(lastExecutionTime, mandate.cadence, currentTime); } const verification = verifyMandateForExecution(mandate, executionCount, lastExecutionTime, currentTime); if (!verification.isValid) { throw new RelayerError(`Cannot execute mandate: ${verification.errors.join(', ')}`); } try { // Real contract interaction - execute pull payment on-chain // Note: This requires the mandate signature to be stored in the mandate object if (!('signature' in mandate)) { throw new RelayerError('Mandate signature required for execution'); } // TODO: Integrate with viem to call contract // const { request } = await publicClient.simulateContract({ // address: registryAddress, // abi: MANDATE_REGISTRY_ABI, // functionName: 'executePullPayment', // args: [ // { // subscriber: mandate.subscriber, // token: mandate.token, // amount: BigInt(mandate.amount), // cadenceInterval: cadenceIntervalToUint8(mandate.cadence.interval), // cadenceCount: BigInt(mandate.cadence.count), // recipient: mandate.recipient, // validAfter: BigInt(mandate.validAfter), // validBefore: BigInt(mandate.validBefore), // nonce: BigInt(mandate.nonce), // maxPayments: mandate.maxPayments ? BigInt(mandate.maxPayments) : 0n, // metadata: mandate.metadata || '', // }, // (mandate as any).signature, // ], // account: walletClient.account, // }); // const txHash = await walletClient.writeContract(request); // Temporary: Return simulated response until contract is deployed const txHash = '0x1234...'; // Update storage stored.executionCount += 1; stored.lastExecutionTime = currentTime; // Create payment record const record = { mandateHash: mandateId, executionCount: stored.executionCount, executedAt: currentTime, txHash, amountPaid: BigInt(mandate.amount), executor: config.account, gasUsed: 150000n, }; // Emit webhook if configured if (config.webhookUrl) { await notifyWebhook(config.webhookUrl, { event: 'payment_executed', mandateId, record, }); } return record; } catch (error) { throw new ContractError(`Failed to execute payment: ${error.message}`, registryAddress); } }; /** * Get execution plan for a mandate */ const getExecutionPlan = async (signedMandate, executionCount = 0) => { const currentTime = Math.floor(Date.now() / 1000); const mandateId = generateMandateId(signedMandate, domain); // Get stored info or use defaults const stored = store.mandates.get(mandateId); const actualExecutionCount = stored?.executionCount ?? executionCount; const lastExecutionTime = stored?.lastExecutionTime; // Calculate next execution const nextExecution = lastExecutionTime ? calculateNextExecution(lastExecutionTime, signedMandate.cadence) : signedMandate.validAfter; // Check if mandate is still active let isActive = true; let inactiveReason; try { validateMandateActive(signedMandate, currentTime); } catch (error) { isActive = false; inactiveReason = error.message; } // Check max payments if (signedMandate.maxPayments && actualExecutionCount >= signedMandate.maxPayments) { isActive = false; inactiveReason = 'Maximum payment count reached'; } return { mandate: signedMandate, nextExecution, executionCount: actualExecutionCount, isActive, inactiveReason, estimatedGas: 200000n, }; }; /** * Verify a payment record */ const verifyPaymentRecord = async (record) => { try { // In a real implementation, this would verify on-chain // For now, we check our local storage const stored = store.mandates.get(record.mandateHash); if (!stored) { return false; } // Verify execution count matches if (stored.executionCount !== record.executionCount) { return false; } return true; } catch (error) { throw new RelayerError(`Failed to verify payment record: ${error.message}`); } }; /** * Get all pending mandates ready for execution */ const getPendingMandates = async () => { const currentTime = Math.floor(Date.now() / 1000); const pending = []; for (const stored of store.mandates.values()) { if (!stored.isActive) continue; const { mandate, lastExecutionTime } = stored; // Check if mandate is still valid try { validateMandateActive(mandate, currentTime); } catch { stored.isActive = false; continue; } // Check if payment is due const isDue = lastExecutionTime ? isPaymentDue(lastExecutionTime, mandate.cadence, currentTime) : currentTime >= mandate.validAfter; if (isDue) { pending.push(mandate); } } return pending; }; /** * Cancel a scheduled mandate * @deprecated Use cancelMandateWithProof for secure cancellation */ const cancelMandate = async (_mandateId) => { throw new RelayerError('cancelMandate is deprecated for security. Use cancelMandateWithProof instead.'); }; /** * Cancel a mandate with signature proof (secure method) */ const cancelMandateWithProof = async (mandate) => { const mandateId = generateMandateId(mandate, domain); const stored = store.mandates.get(mandateId); if (!stored) { throw new RelayerError(`Mandate ${mandateId} not found`); } try { // Real contract interaction - cancel with proof // TODO: Integrate with viem to call contract when deployed // Update local storage stored.isActive = false; return true; } catch (error) { throw new ContractError(`Failed to cancel mandate: ${error.message}`, registryAddress); } }; // Return client interface return { scheduleMandate, executePullPayment, getExecutionPlan, verifyPaymentRecord, getPendingMandates, cancelMandate, cancelMandateWithProof, }; } /** * Helper function to notify webhook */ async function notifyWebhook(url, payload) { try { // Use globalThis.fetch for Node.js 18+ compatibility const fetchFn = typeof globalThis !== 'undefined' && globalThis.fetch ? globalThis.fetch : null; if (!fetchFn) { // eslint-disable-next-line no-console console.warn('fetch not available, skipping webhook notification'); return; } await fetchFn(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); } catch (error) { // Log but don't throw - webhook failures shouldn't block execution // eslint-disable-next-line no-console console.error('Webhook notification failed:', error); } } /** * Create a simple scheduler that periodically checks for pending mandates * * @param relayer - RelayerClient instance * @param intervalSeconds - Check interval in seconds * @returns Stop function to cancel the scheduler * * @example * ```typescript * const stop = createScheduler(relayer, 60); // Check every minute * * // Later, stop the scheduler * stop(); * ``` */ export function createScheduler(relayer, intervalSeconds = 60) { const interval = setInterval(async () => { try { const pending = await relayer.getPendingMandates(); for (let i = 0; i < pending.length; i++) { const mandateId = '0x...'; // Would calculate proper ID try { await relayer.executePullPayment(mandateId); // eslint-disable-next-line no-console console.log(`Executed payment for mandate ${mandateId}`); } catch (error) { // eslint-disable-next-line no-console console.error(`Failed to execute mandate ${mandateId}:`, error); } } } catch (error) { // eslint-disable-next-line no-console console.error('Scheduler error:', error); } }, intervalSeconds * 1000); return () => clearInterval(interval); } //# sourceMappingURL=index.js.map