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