@anuragchvn-blip/mandatekit
Version:
Production-ready Web3 autopay SDK for crypto-based recurring payments using EIP-712 mandates
363 lines • 13.1 kB
JavaScript
/**
* 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