UNPKG

@smoothsend/sdk

Version:

Multi-chain gasless transaction SDK for seamless dApp integration

1,279 lines (1,272 loc) 62.3 kB
import axios from 'axios'; // Chain to ecosystem mapping const CHAIN_ECOSYSTEM_MAP = { 'avalanche': 'evm', 'aptos-testnet': 'aptos' }; // Error Types class SmoothSendError extends Error { constructor(message, code, chain, details) { super(message); this.code = code; this.chain = chain; this.details = details; this.name = 'SmoothSendError'; } } // Enhanced error codes for signature verification const APTOS_ERROR_CODES = { // Signature verification errors MISSING_SIGNATURE: 'APTOS_MISSING_SIGNATURE', MISSING_PUBLIC_KEY: 'APTOS_MISSING_PUBLIC_KEY', INVALID_SIGNATURE_FORMAT: 'APTOS_INVALID_SIGNATURE_FORMAT', INVALID_PUBLIC_KEY_FORMAT: 'APTOS_INVALID_PUBLIC_KEY_FORMAT', ADDRESS_MISMATCH: 'APTOS_ADDRESS_MISMATCH', SIGNATURE_VERIFICATION_FAILED: 'APTOS_SIGNATURE_VERIFICATION_FAILED', // Transaction errors MISSING_TRANSACTION_DATA: 'APTOS_MISSING_TRANSACTION_DATA', INVALID_TRANSACTION_FORMAT: 'APTOS_INVALID_TRANSACTION_FORMAT', // Address validation errors EMPTY_ADDRESS: 'APTOS_EMPTY_ADDRESS', INVALID_ADDRESS_FORMAT: 'APTOS_INVALID_ADDRESS_FORMAT', // General errors QUOTE_ERROR: 'APTOS_QUOTE_ERROR', EXECUTE_ERROR: 'APTOS_EXECUTE_ERROR', BALANCE_ERROR: 'APTOS_BALANCE_ERROR', TOKEN_INFO_ERROR: 'APTOS_TOKEN_INFO_ERROR', STATUS_ERROR: 'APTOS_STATUS_ERROR', MOVE_CALL_ERROR: 'APTOS_MOVE_CALL_ERROR', UNSUPPORTED_TOKEN: 'APTOS_UNSUPPORTED_TOKEN' }; // Minimal static configs - most data will be fetched dynamically from relayers // Multi-chain architecture maintained for future expansion const CHAIN_CONFIGS = { avalanche: { name: 'avalanche-fuji', displayName: 'Avalanche Fuji Testnet', chainId: 43113, rpcUrl: 'https://api.avax-test.network/ext/bc/C/rpc', relayerUrl: 'https://smoothsendevm.onrender.com', explorerUrl: 'https://testnet.snowtrace.io', tokens: ['USDC'], nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 } }, 'aptos-testnet': { name: 'aptos-testnet', displayName: 'Aptos Testnet', chainId: 2, // Aptos testnet chain ID rpcUrl: 'https://fullnode.testnet.aptoslabs.com/v1', relayerUrl: 'https://smoothsendrelayerworking.onrender.com/api/v1/relayer/', explorerUrl: 'https://explorer.aptoslabs.com', tokens: ['USDC', 'APT'], nativeCurrency: { name: 'Aptos', symbol: 'APT', decimals: 8 } } }; function getChainConfig(chain) { return CHAIN_CONFIGS[chain]; } function getAllChainConfigs() { return CHAIN_CONFIGS; } // These will be fetched dynamically from relayers // Keep minimal fallbacks for offline scenarios const TOKEN_DECIMALS = { 'USDC': 6, 'AVAX': 18, 'APT': 8 // Additional token decimals will be added as new chains are supported }; function getTokenDecimals(token) { return TOKEN_DECIMALS[token] || 18; } class HttpClient { constructor(baseURL, timeout = 30000) { this.client = axios.create({ baseURL, timeout, headers: { 'Content-Type': 'application/json', }, }); // Request interceptor this.client.interceptors.request.use((config) => { // Add timestamp to prevent caching config.params = { ...config.params, _t: Date.now() }; return config; }, (error) => Promise.reject(error)); // Response interceptor this.client.interceptors.response.use((response) => response, (error) => { if (error.response) { // Server responded with error status const { status, data } = error.response; throw new SmoothSendError(data?.error || `HTTP Error ${status}`, `HTTP_${status}`, undefined, data); } else if (error.request) { // Network error throw new SmoothSendError('Network error - unable to connect to relayer', 'NETWORK_ERROR', undefined, error.message); } else { // Other error throw new SmoothSendError(error.message || 'Unknown error', 'UNKNOWN_ERROR', undefined, error); } }); } async get(url, config) { try { const response = await this.client.get(url, config); return { success: true, data: response.data, }; } catch (error) { return this.handleError(error); } } async post(url, data, config) { try { const response = await this.client.post(url, data, config); return { success: true, data: response.data, }; } catch (error) { return this.handleError(error); } } async put(url, data, config) { try { const response = await this.client.put(url, data, config); return { success: true, data: response.data, }; } catch (error) { return this.handleError(error); } } async delete(url, config) { try { const response = await this.client.delete(url, config); return { success: true, data: response.data, }; } catch (error) { return this.handleError(error); } } handleError(error) { if (error instanceof SmoothSendError) { return { success: false, error: error.message, details: error.details, }; } return { success: false, error: error.message || 'Unknown error occurred', details: error, }; } // Utility method for retrying requests async retry(operation, maxRetries = 3, delay = 1000) { let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const result = await operation(); if (result.success) { return result; } lastError = new Error(result.error); } catch (error) { lastError = error; } if (attempt < maxRetries) { await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, attempt))); } } return { success: false, error: `Operation failed after ${maxRetries + 1} attempts: ${lastError.message}`, details: lastError, }; } } class ChainConfigService { constructor() { this.configCache = new Map(); this.cacheExpiry = new Map(); this.CACHE_TTL = 5 * 60 * 1000; // 5 minutes } /** * Fetch chain configuration from relayer's API endpoints */ async fetchChainConfig(relayerUrl, chainName) { const cacheKey = `${relayerUrl}:${chainName || 'all'}`; const now = Date.now(); // Check cache first if (this.configCache.has(cacheKey) && this.cacheExpiry.get(cacheKey) > now) { const cached = this.configCache.get(cacheKey); return cached ? [cached] : []; } try { const httpClient = new HttpClient(relayerUrl); const response = await httpClient.get('/chains'); if (!response.success || !response.data?.chains) { throw new Error(`Failed to fetch chains from ${relayerUrl}: ${response.error || 'No data'}`); } const chains = response.data.chains; const configs = []; for (const chain of chains) { const config = this.mapRelayerChainToConfig(chain, relayerUrl); configs.push(config); // Cache individual chain configs const chainCacheKey = `${relayerUrl}:${chain.name}`; this.configCache.set(chainCacheKey, config); this.cacheExpiry.set(chainCacheKey, now + this.CACHE_TTL); } // Cache all chains response this.cacheExpiry.set(cacheKey, now + this.CACHE_TTL); return chainName ? configs.filter(c => this.matchesChainName(c, chainName)) : configs; } catch (error) { console.warn(`Failed to fetch chain config from ${relayerUrl}:`, error); throw error; } } /** * Get configuration for a specific supported chain */ async getChainConfig(chain, fallbackConfig) { const relayerUrl = this.getRelayerUrlForChain(chain, fallbackConfig); try { const configs = await this.fetchChainConfig(relayerUrl); const config = configs.find(c => this.matchesChain(c, chain)); if (config) { return config; } } catch (error) { console.warn(`Failed to fetch dynamic config for ${chain}, using fallback:`, error); } // Fallback to static config if dynamic fetch fails if (fallbackConfig) { return { ...fallbackConfig, tokens: this.getDefaultTokensForChain(chain), }; } throw new Error(`No configuration available for chain: ${chain}`); } /** * Get all available chain configurations */ async getAllChainConfigs(fallbackConfigs) { const configs = {}; // Currently supports Avalanche and Aptos relayers - architecture ready for additional relayers const relayerUrls = [ 'https://smoothsendevm.onrender.com', // Avalanche relayer 'https://smoothsendrelayerworking.onrender.com/api/v1/relayer' // Aptos relayer // Additional relayer URLs will be added here as they become available ]; for (const relayerUrl of relayerUrls) { try { const chainConfigs = await this.fetchChainConfig(relayerUrl); for (const config of chainConfigs) { const chainKey = this.getChainKey(config); if (chainKey) { // Don't override existing configs (first relayer wins) if (!configs[chainKey]) { configs[chainKey] = config; } } } } catch (error) { console.warn(`Failed to fetch from ${relayerUrl}:`, error); } } // Add fallback configs for any missing chains if (fallbackConfigs) { for (const [chain, fallback] of Object.entries(fallbackConfigs)) { if (!configs[chain]) { configs[chain] = { ...fallback, tokens: this.getDefaultTokensForChain(chain), }; } } } return configs; } /** * Clear the configuration cache */ clearCache() { this.configCache.clear(); this.cacheExpiry.clear(); } /** * Set custom cache TTL */ setCacheTtl(ttlMs) { // Update the cache TTL for future requests Object.defineProperty(this, 'CACHE_TTL', { value: ttlMs }); } mapRelayerChainToConfig(chain, relayerUrl) { return { name: chain.name, displayName: chain.displayName, chainId: chain.chainId, rpcUrl: chain.rpcUrl || this.getDefaultRpcUrl(chain.name), relayerUrl: relayerUrl, explorerUrl: chain.explorerUrl, tokens: chain.tokens || [], nativeCurrency: this.getNativeCurrencyForChain(chain.name), contractAddress: chain.contractAddress, }; } getRelayerUrlForChain(chain, fallbackConfig) { if (fallbackConfig?.relayerUrl) { return fallbackConfig.relayerUrl; } // Default relayer URLs switch (chain) { case 'avalanche': return 'https://smoothsendevm.onrender.com'; case 'aptos-testnet': return 'https://smoothsendrelayerworking.onrender.com/api/v1/relayer'; default: // For unknown chains, try Avalanche relayer (EVM-compatible chains may work) console.warn(`Unknown chain ${chain}, defaulting to Avalanche relayer`); return 'https://smoothsendevm.onrender.com'; } } matchesChain(config, chain) { const chainName = config.name.toLowerCase(); switch (chain) { case 'avalanche': return chainName.includes('avalanche') || chainName.includes('fuji') || chainName.includes('avax'); case 'aptos-testnet': return chainName.includes('aptos') && chainName.includes('testnet'); default: return false; } } matchesChainName(config, chainName) { return config.name.toLowerCase().includes(chainName.toLowerCase()); } getChainKey(config) { const name = config.name.toLowerCase(); if (name.includes('avalanche') || name.includes('fuji') || name.includes('avax')) { return 'avalanche'; } if (name.includes('aptos') && name.includes('testnet')) { return 'aptos-testnet'; } // Additional chain matching logic will be added here as new chains are supported return null; } getDefaultRpcUrl(chainName) { const name = chainName.toLowerCase(); if (name.includes('avalanche') || name.includes('fuji')) { return 'https://api.avax-test.network/ext/bc/C/rpc'; } // Additional RPC URLs will be added here as new chains are supported return ''; } getNativeCurrencyForChain(chainName) { const name = chainName.toLowerCase(); if (name.includes('avalanche') || name.includes('fuji')) { return { name: 'Avalanche', symbol: 'AVAX', decimals: 18 }; } if (name.includes('aptos')) { return { name: 'Aptos', symbol: 'APT', decimals: 8 }; } // Additional native currency mappings will be added here as new chains are supported return { name: 'Unknown', symbol: 'UNK', decimals: 18 }; } getDefaultTokensForChain(chain) { switch (chain) { case 'avalanche': return ['USDC']; case 'aptos-testnet': return ['USDC', 'APT']; default: return []; } } } // Export singleton instance const chainConfigService = new ChainConfigService(); /** * EVM Multi-Chain Adapter * Handles all EVM-compatible chains (Avalanche, Polygon, Ethereum, Arbitrum, Base) * Routes requests to the appropriate chain endpoint on the EVM relayer */ class EVMAdapter { constructor(chain, config, relayerUrl) { // Validate this is an EVM chain if (CHAIN_ECOSYSTEM_MAP[chain] !== 'evm') { throw new SmoothSendError(`EVMAdapter can only handle EVM chains, got: ${chain}`, 'INVALID_CHAIN_FOR_ADAPTER', chain); } this.chain = chain; this.config = config; this.httpClient = new HttpClient(relayerUrl, 30000); } /** * Build API path with chain name for EVM relayer * EVM relayer uses /chains/{chainName} prefix for most endpoints */ getApiPath(endpoint) { // Some endpoints don't use chain prefix const noChainPrefixEndpoints = ['/nonce', '/health', '/chains']; if (noChainPrefixEndpoints.some(prefix => endpoint.startsWith(prefix))) { return endpoint; } return `/chains/${this.chain}${endpoint}`; } async getQuote(request) { try { const response = await this.httpClient.post('/quote', { chainName: this.chain === 'avalanche' ? 'avalanche-fuji' : this.chain, from: request.from, to: request.to, tokenSymbol: request.token, amount: request.amount }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const quoteData = response.data; return { amount: request.amount, relayerFee: quoteData.relayerFee, total: (BigInt(request.amount) + BigInt(quoteData.relayerFee)).toString(), feePercentage: quoteData.feePercentage || 0, contractAddress: quoteData.contractAddress || this.config.relayerUrl }; } catch (error) { throw new SmoothSendError(`Failed to get EVM quote: ${error instanceof Error ? error.message : String(error)}`, 'EVM_QUOTE_ERROR', this.chain); } } async prepareTransfer(request, quote) { try { // Get user nonce first const nonce = await this.getNonce(request.from); const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now const response = await this.httpClient.post('/prepare-signature', { chainName: this.chain === 'avalanche' ? 'avalanche-fuji' : this.chain, from: request.from, to: request.to, tokenSymbol: request.token, amount: request.amount, relayerFee: quote.relayerFee, nonce, deadline }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const signatureData = response.data; return { domain: signatureData.typedData.domain, types: signatureData.typedData.types, message: signatureData.typedData.message, primaryType: signatureData.typedData.primaryType }; } catch (error) { throw new SmoothSendError(`Failed to prepare EVM transfer: ${error instanceof Error ? error.message : String(error)}`, 'EVM_PREPARE_ERROR', this.chain); } } async executeTransfer(signedData) { try { const response = await this.httpClient.post('/relay-transfer', signedData.transferData); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const transferData = response.data; return { success: transferData.success || true, txHash: transferData.txHash, blockNumber: transferData.blockNumber, gasUsed: transferData.gasUsed, transferId: transferData.transferId, explorerUrl: transferData.explorerUrl, fee: transferData.fee, executionTime: transferData.executionTime }; } catch (error) { throw new SmoothSendError(`Failed to execute EVM transfer: ${error instanceof Error ? error.message : String(error)}`, 'EVM_EXECUTE_ERROR', this.chain); } } /** * EVM-specific batch transfer support * Takes advantage of the EVM relayer's native batch capabilities */ async executeBatchTransfer(signedTransfers) { try { const response = await this.httpClient.post('/relay-batch-transfer', { transfers: signedTransfers.map(transfer => transfer.transferData) }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } return response.data.results || []; } catch (error) { throw new SmoothSendError(`Failed to execute EVM batch transfer: ${error instanceof Error ? error.message : String(error)}`, 'EVM_BATCH_ERROR', this.chain); } } async getTokenInfo(token) { try { const response = await this.httpClient.get(this.getApiPath('/tokens')); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const tokens = response.data.tokens || {}; const tokenInfo = tokens[token.toUpperCase()]; if (!tokenInfo) { throw new Error(`Token ${token} not supported on ${this.chain}`); } return { symbol: tokenInfo.symbol, address: tokenInfo.address, decimals: tokenInfo.decimals, name: tokenInfo.name }; } catch (error) { throw new SmoothSendError(`Failed to get EVM token info: ${error instanceof Error ? error.message : String(error)}`, 'EVM_TOKEN_INFO_ERROR', this.chain); } } async getNonce(address) { try { const response = await this.httpClient.get('/nonce', { params: { chainName: this.chain, userAddress: address } }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } return response.data.nonce?.toString() || '0'; } catch (error) { throw new SmoothSendError(`Failed to get EVM nonce: ${error instanceof Error ? error.message : String(error)}`, 'EVM_NONCE_ERROR', this.chain); } } async getTransactionStatus(txHash) { try { const response = await this.httpClient.get(this.getApiPath(`/status/${txHash}`)); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } return response.data; } catch (error) { throw new SmoothSendError(`Failed to get EVM transaction status: ${error instanceof Error ? error.message : String(error)}`, 'EVM_STATUS_ERROR', this.chain); } } validateAddress(address) { // EVM address validation (0x prefix, 40 hex characters) return /^0x[a-fA-F0-9]{40}$/.test(address); } async validateAmount(amount, token) { try { const amountBN = BigInt(amount); return amountBN > 0n; } catch { return false; } } /** * EVM-specific gas estimation */ async estimateGas(transfers) { try { const response = await this.httpClient.post(this.getApiPath('/estimate-gas'), { transfers }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } return response.data; } catch (error) { throw new SmoothSendError(`Failed to estimate EVM gas: ${error instanceof Error ? error.message : String(error)}`, 'EVM_GAS_ESTIMATE_ERROR', this.chain); } } /** * EVM-specific permit support check */ async supportsPermit(tokenAddress) { try { const response = await this.httpClient.get(this.getApiPath(`/permit-support/${tokenAddress}`)); if (!response.success) { return false; // If endpoint fails, assume no permit support } return response.data.supportsPermit || false; } catch (error) { // If endpoint doesn't exist, assume no permit support return false; } } } /** * Aptos Multi-Chain Adapter * Handles all Aptos chains (aptos-testnet, aptos-mainnet) * Routes requests to the appropriate chain endpoint on the Aptos relayer * Supports Aptos-specific features like gasless transactions and Move-based contracts */ class AptosAdapter { constructor(chain, config, relayerUrl) { // Validate this is an Aptos chain if (CHAIN_ECOSYSTEM_MAP[chain] !== 'aptos') { throw new SmoothSendError(`AptosAdapter can only handle Aptos chains, got: ${chain}`, 'INVALID_CHAIN_FOR_ADAPTER', chain); } this.chain = chain; this.config = config; this.httpClient = new HttpClient(relayerUrl, 30000); } /** * Build API path with chain name for Aptos relayer */ getApiPath(endpoint) { return `/${this.chain}${endpoint}`; } async getQuote(request) { try { const response = await this.httpClient.post(this.getApiPath('/gasless/quote'), { fromAddress: request.from, toAddress: request.to, amount: request.amount, coinType: this.getAptosTokenAddress(request.token) }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const responseData = response.data; const quote = responseData.quote; return { amount: request.amount, relayerFee: quote.relayerFee, total: (BigInt(request.amount) + BigInt(quote.relayerFee)).toString(), feePercentage: 0, // Aptos uses different fee structure contractAddress: responseData.transactionData.function.split('::')[0], // Store Aptos-specific data for later use aptosTransactionData: responseData.transactionData }; } catch (error) { throw new SmoothSendError(`Failed to get Aptos quote: ${error instanceof Error ? error.message : String(error)}`, APTOS_ERROR_CODES.QUOTE_ERROR, this.chain); } } async prepareTransfer(request, quote) { // For Aptos, the transaction data is provided in the quote response // The user will sign this transaction directly in their wallet const aptosQuote = quote; if (!aptosQuote.aptosTransactionData) { throw new SmoothSendError('Missing Aptos transaction data from quote', APTOS_ERROR_CODES.MISSING_TRANSACTION_DATA, this.chain); } // Return the transaction data that needs to be signed // NOTE: After signing, you must serialize the transaction and authenticator // using the Aptos SDK and provide them as transactionBytes and authenticatorBytes return { domain: null, // Aptos doesn't use domain separation like EVM types: null, message: aptosQuote.aptosTransactionData, primaryType: 'AptosTransaction', // Add metadata to help with serialization - using any type for flexibility metadata: { requiresSerialization: true, serializationInstructions: 'After signing, serialize the SimpleTransaction and AccountAuthenticator using Aptos SDK', expectedFormat: 'transactionBytes and authenticatorBytes as number arrays' } }; } async executeTransfer(signedData) { try { // Validate that we have the required serialized transaction data this.validateSerializedTransactionData(signedData); const response = await this.httpClient.post(this.getApiPath('/gasless/submit'), { transactionBytes: signedData.transferData.transactionBytes, authenticatorBytes: signedData.transferData.authenticatorBytes, functionName: signedData.transferData.functionName || 'smoothsend_transfer' }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const transferData = response.data; return { success: transferData.success || true, // Use standardized field names (txHash, transferId) txHash: transferData.txHash || transferData.hash, // Support both formats transferId: transferData.transferId || transferData.transactionId, // Support both formats explorerUrl: this.buildAptosExplorerUrl(transferData.txHash || transferData.hash), // Standard fields gasUsed: transferData.gasUsed, // Aptos-specific fields from enhanced response format gasFeePaidBy: transferData.gasFeePaidBy || 'relayer', userPaidAPT: transferData.userPaidAPT || false, vmStatus: transferData.vmStatus, sender: transferData.sender, chain: transferData.chain, relayerFee: transferData.relayerFee, message: transferData.message }; } catch (error) { throw new SmoothSendError(`Failed to execute Aptos transfer: ${error instanceof Error ? error.message : String(error)}`, APTOS_ERROR_CODES.EXECUTE_ERROR, this.chain); } } async getBalance(address, token) { try { const response = await this.httpClient.get(this.getApiPath(`/balance/${address}`)); // Handle both successful and error responses from HttpClient if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const balanceData = response.data; return [{ token: balanceData?.symbol || token || 'USDC', balance: balanceData?.balance?.toString() || '0', decimals: balanceData?.decimals || 6, symbol: balanceData?.symbol || token || 'USDC', name: balanceData?.name || 'USD Coin (Testnet)' }]; } catch (error) { throw new SmoothSendError(`Failed to get Aptos balance: ${error instanceof Error ? error.message : String(error)}`, APTOS_ERROR_CODES.BALANCE_ERROR, this.chain); } } async getTokenInfo(token) { try { const response = await this.httpClient.get(this.getApiPath('/tokens')); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } const tokens = response.data.tokens || {}; const tokenInfo = tokens[token.toUpperCase()]; if (!tokenInfo) { throw new Error(`Token ${token} not supported on ${this.chain}`); } return { symbol: tokenInfo.symbol, address: tokenInfo.address, decimals: tokenInfo.decimals, name: tokenInfo.name }; } catch (error) { throw new SmoothSendError(`Failed to get Aptos token info: ${error instanceof Error ? error.message : String(error)}`, APTOS_ERROR_CODES.TOKEN_INFO_ERROR, this.chain); } } async getNonce(address) { // Aptos uses sequence numbers instead of nonces // For compatibility, we return a timestamp-based value // The actual sequence number is managed by the Aptos blockchain return Date.now().toString(); } async getTransactionStatus(txHash) { try { const response = await this.httpClient.get(this.getApiPath(`/status/${txHash}`)); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } return response.data; } catch (error) { throw new SmoothSendError(`Failed to get Aptos transaction status: ${error instanceof Error ? error.message : String(error)}`, APTOS_ERROR_CODES.STATUS_ERROR, this.chain); } } validateAddress(address) { // Aptos address validation (0x prefix, up to 64 hex characters) // Aptos addresses can be shorter and are automatically padded return /^0x[a-fA-F0-9]{1,64}$/.test(address); } async validateAmount(amount, token) { try { const amountBN = BigInt(amount); return amountBN > 0n; } catch { return false; } } /** * Get Aptos token address from symbol */ getAptosTokenAddress(tokenSymbol) { // This would typically come from the chain configuration if (tokenSymbol.toUpperCase() === 'USDC') { if (this.chain === 'aptos-testnet') { return '0x3c27315fb69ba6e4b960f1507d1cefcc9a4247869f26a8d59d6b7869d23782c::test_coins::USDC'; } } throw new SmoothSendError(`Unsupported token: ${tokenSymbol} on ${this.chain}`, APTOS_ERROR_CODES.UNSUPPORTED_TOKEN, this.chain); } /** * Build Aptos explorer URL for transaction */ buildAptosExplorerUrl(txHash) { if (this.chain === 'aptos-testnet') { return `https://explorer.aptoslabs.com/txn/${txHash}?network=testnet`; } return `https://explorer.aptoslabs.com/txn/${txHash}`; } /** * Validate serialized transaction data for the new safe endpoint * @param signedData The signed transfer data to validate */ validateSerializedTransactionData(signedData) { if (!signedData.transferData?.transactionBytes) { throw new SmoothSendError('Serialized transaction bytes are required for Aptos transactions', APTOS_ERROR_CODES.MISSING_TRANSACTION_DATA, this.chain); } if (!signedData.transferData?.authenticatorBytes) { throw new SmoothSendError('Serialized authenticator bytes are required for Aptos transactions', APTOS_ERROR_CODES.MISSING_SIGNATURE, this.chain); } // Validate that transaction bytes is an array of numbers (0-255) if (!Array.isArray(signedData.transferData.transactionBytes) || !signedData.transferData.transactionBytes.every((b) => typeof b === 'number' && b >= 0 && b <= 255)) { throw new SmoothSendError('Invalid transaction bytes format. Expected array of numbers 0-255.', APTOS_ERROR_CODES.INVALID_SIGNATURE_FORMAT, this.chain); } // Validate that authenticator bytes is an array of numbers (0-255) if (!Array.isArray(signedData.transferData.authenticatorBytes) || !signedData.transferData.authenticatorBytes.every((b) => typeof b === 'number' && b >= 0 && b <= 255)) { throw new SmoothSendError('Invalid authenticator bytes format. Expected array of numbers 0-255.', APTOS_ERROR_CODES.INVALID_PUBLIC_KEY_FORMAT, this.chain); } } /** * Enhanced address validation with detailed error messages * @param address The address to validate * @returns true if valid, throws error if invalid */ validateAddressStrict(address) { if (!address) { throw new SmoothSendError('Address cannot be empty', APTOS_ERROR_CODES.EMPTY_ADDRESS, this.chain); } // Aptos address validation (0x prefix, up to 64 hex characters) if (!/^0x[a-fA-F0-9]{1,64}$/.test(address)) { throw new SmoothSendError('Invalid Aptos address format. Must start with 0x and contain 1-64 hex characters.', APTOS_ERROR_CODES.INVALID_ADDRESS_FORMAT, this.chain); } return true; } /** * Verify that a public key corresponds to an expected address * This mirrors the enhanced verification in the relayer * @param publicKey The public key to verify * @param expectedAddress The expected address * @returns true if they match */ async verifyPublicKeyAddress(publicKey, expectedAddress) { try { // This would typically use the Aptos SDK to derive address from public key // For now, we'll do basic validation and let the relayer handle the actual verification this.validateAddressStrict(expectedAddress); if (!publicKey || !publicKey.startsWith('0x')) { return false; } // The actual verification is done by the relayer using the Aptos SDK // This is just a preliminary check return true; } catch (error) { return false; } } /** * Enhanced transaction preparation with better signature data structure * @param request Transfer request * @param quote Transfer quote * @returns Signature data with enhanced structure */ async prepareTransferEnhanced(request, quote) { const baseSignatureData = await this.prepareTransfer(request, quote); return { ...baseSignatureData, metadata: { chain: this.chain, fromAddress: request.from, toAddress: request.to, amount: request.amount, token: request.token, relayerFee: quote.relayerFee, signatureVersion: '2.0', // Version for tracking signature format changes requiresPublicKey: true, // Indicates this chain requires public key for verification verificationMethod: 'ed25519_with_address_derivation' // Indicates verification method used } }; } /** * Aptos-specific Move contract interaction */ async callMoveFunction(functionName, args) { try { const response = await this.httpClient.post(this.getApiPath('/move/call'), { function: functionName, arguments: args }); if (!response.success) { throw new Error(response.error || 'Unknown error occurred'); } return response.data; } catch (error) { throw new SmoothSendError(`Failed to call Move function: ${error instanceof Error ? error.message : String(error)}`, APTOS_ERROR_CODES.MOVE_CALL_ERROR, this.chain); } } } class SmoothSendSDK { constructor(config = {}) { this.adapters = new Map(); this.eventListeners = []; this.initialized = false; this.initializationPromise = null; this.config = { timeout: 30000, retries: 3, useDynamicConfig: true, // Enable dynamic config by default configCacheTtl: 5 * 60 * 1000, // 5 minutes relayerUrls: { evm: 'https://smoothsendevm.onrender.com', aptos: 'https://smoothsendrelayerworking.onrender.com/api/v1/relayer' }, ...config }; // Set custom cache TTL if provided if (this.config.configCacheTtl) { chainConfigService.setCacheTtl(this.config.configCacheTtl); } // Don't initialize immediately, wait for first method call to avoid blocking constructor } /** * Initialize adapters with dynamic or static configuration */ async initializeAdapters() { if (this.initialized) return; if (this.initializationPromise) { await this.initializationPromise; return; } this.initializationPromise = this._doInitialize(); await this.initializationPromise; } async _doInitialize() { try { if (this.config.useDynamicConfig) { await this.initializeDynamicAdapters(); } else { this.initializeStaticAdapters(); } this.initialized = true; } catch (error) { console.warn('Dynamic configuration failed, falling back to static:', error); this.initializeStaticAdapters(); this.initialized = true; } } async initializeDynamicAdapters() { try { // Fetch dynamic configurations from both relayers const supportedChains = await this.fetchSupportedChains(); // Initialize adapters for all supported chains for (const chain of supportedChains) { const chainConfig = await this.fetchChainConfig(chain); const finalConfig = { ...chainConfig, ...this.config.customChainConfigs?.[chain] }; this.createAdapter(chain, finalConfig); } } catch (error) { console.error('Failed to initialize dynamic adapters:', error); throw error; } } initializeStaticAdapters() { // Initialize all supported chains with static configuration const supportedChains = [ 'avalanche', 'aptos-testnet' ]; for (const chain of supportedChains) { try { const config = this.getDefaultChainConfig(chain); const finalConfig = { ...config, ...this.config.customChainConfigs?.[chain] }; this.createAdapter(chain, finalConfig); } catch (error) { console.warn(`Failed to initialize ${chain}:`, error); } } } createAdapter(chain, config) { if (!this.config.relayerUrls) { throw new SmoothSendError('Relayer URLs not configured', 'MISSING_RELAYER_URLS'); } const ecosystem = CHAIN_ECOSYSTEM_MAP[chain]; const relayerUrl = this.config.relayerUrls[ecosystem]; if (!relayerUrl) { throw new SmoothSendError(`No relayer URL configured for ${ecosystem} ecosystem`, 'MISSING_RELAYER_URL', chain); } // Route to the appropriate ecosystem-specific adapter if (ecosystem === 'evm') { this.adapters.set(chain, new EVMAdapter(chain, config, relayerUrl)); } else if (ecosystem === 'aptos') { this.adapters.set(chain, new AptosAdapter(chain, config, relayerUrl)); } else { throw new SmoothSendError(`Unsupported ecosystem: ${ecosystem}`, 'UNSUPPORTED_ECOSYSTEM', chain); } } /** * Fetch supported chains from both relayers */ async fetchSupportedChains() { const chains = []; try { // Fetch from EVM relayer if (this.config.relayerUrls?.evm) { const evmClient = new HttpClient(this.config.relayerUrls.evm); const response = await evmClient.get('/chains'); // The EVM relayer returns chain names directly chains.push(...(response.data.chains || [])); } } catch (error) { console.warn('Failed to fetch EVM chains:', error); } try { // Fetch from Aptos relayer if (this.config.relayerUrls?.aptos) { const aptosClient = new HttpClient(this.config.relayerUrls.aptos); const response = await aptosClient.get('/chains'); // The Aptos relayer returns chain names directly chains.push(...(response.data.chains || [])); } } catch (error) { console.warn('Failed to fetch Aptos chains:', error); } return chains; } /** * Fetch chain configuration from the appropriate relayer */ async fetchChainConfig(chain) { const ecosystem = CHAIN_ECOSYSTEM_MAP[chain]; const relayerUrl = this.config.relayerUrls?.[ecosystem]; if (!relayerUrl) { throw new Error(`No relayer URL for ${ecosystem} ecosystem`); } const client = new HttpClient(relayerUrl); const response = await client.get(`/${chain}/info`); const info = response.data.info; return { name: info.name, displayName: info.name, chainId: info.chainId, rpcUrl: info.rpcUrl, relayerUrl: relayerUrl, explorerUrl: info.explorerUrl, tokens: Object.keys(info.tokens || {}), nativeCurrency: { name: ecosystem === 'evm' ? 'Ether' : 'APT', symbol: ecosystem === 'evm' ? 'ETH' : 'APT', decimals: ecosystem === 'evm' ? 18 : 8 } }; } /** * Get default configuration for a chain (fallback when dynamic config fails) */ getDefaultChainConfig(chain) { const ecosystem = CHAIN_ECOSYSTEM_MAP[chain]; const relayerUrl = this.config.relayerUrls?.[ecosystem] || ''; // Return minimal default configuration return { name: chain, displayName: chain, chainId: 0, // Will be updated dynamically rpcUrl: '', relayerUrl: relayerUrl, explorerUrl: '', tokens: ['USDC'], nativeCurrency: { name: ecosystem === 'evm' ? 'Ether' : 'APT', symbol: ecosystem === 'evm' ? 'ETH' : 'APT', decimals: ecosystem === 'evm' ? 18 : 8 } }; } /** * Refresh chain configurations from relayers */ async refreshChainConfigs() { chainConfigService.clearCache(); this.adapters.clear(); this.initialized = false; this.initializationPromise = null; await this.initializeAdapters(); } // Event handling addEventListener(listener) { this.eventListeners.push(listener); } removeEventListener(listener) { const index = this.eventListeners.indexOf(listener); if (index > -1) { this.eventListeners.splice(index, 1); } } emitEvent(event) { this.eventListeners.forEach(listener => { try { listener(event); } catch (error) { console.error('Error in event listener:', error); } }); } // Core transfer methods async getQuote(request) { await this.initializeAdapters(); const adapter = this.getAdapter(request.chain); this.emitEvent({ type: 'transfer_initiated', data: { request }, timestamp: Date.now(), chain: request.chain }); try { const quote = await adapter.getQuote(request); return quote; } catch (error) { this.emitEvent({ type: 'transfer_failed', data: { error: error instanceof Error ? error.message : String(error), step: 'quote' }, timestamp: Date.now(), chain: request.chain }); throw error; } } async prepareTransfer(request, quote) { await this.initializeAdapters(); const adapter = this.getAdapter(request.chain); try { const signatureData = await adapter.prepareTransfer(request, quote); return signatureData; } catch (error) { this.emitEvent({ type: 'transfer_failed', data: { error: error instanceof Error ? error.message : String(error), step: 'prepare' }, timestamp: Date.now(), chain: request.chain }); throw error; } } async executeTransfer(signedData, chain) { await this.initializeAdapters(); const adapter = this.getAdapter(chain); this.emitEvent({ type: 'transfer_submitted', data: { signedData }, timestamp: Date.now(), chain }); try { const result = await adapter.executeTransfer(signedData); this.emitEvent({ type: 'transfer_confirmed', data: { result }, timestamp: Date.now(), chain }); return result; } catch (error) { this.emitEvent({ type: 'transfer_failed', data: { error: error instanceof Error ? error.message : String(error), step: 'execute' }, timestamp: Date.now(), chain }); throw error; } } // Convenience method for complete transfer flow async transfer(request, signer // Wallet signer (ethers.Signer for EVM, Aptos account for Aptos) ) { await this.initializeAdapters(); // Step 1: Get quote const quote = await this.getQuote(request); // Step 2: Prepare signature data const signatureData = await this.prepareTransfer(request, quote); // Step 3: Sign the data let signature; let transferData; let signatureType; const ecosystem = CHAIN_ECOSYSTEM_MAP[request.chain]; if (ecosystem === 'evm') { // EIP-712 signing for EVM chains (Avalanche) signature = await signer.signTypedData(signatureData.domain, signatureData.types, signatureData.message); transferData = { chainName: request.chain === 'avalanche' ? 'avalanche-fuji' : request.chain, from: request.from, to: request.to, tokenSymbol: request.token, amount: request.amount, relayerFee: quote.relayerFee, nonce: signatureData.message.nonce, deadline: signatureData.message.deadline, }; signatureType = 'EIP712'; } else if (ecosystem === 'aptos') { // Aptos signing - requires transaction serialization for secure relayer const signedTransaction =