@smoothsend/sdk
Version:
Multi-chain gasless transaction SDK for seamless dApp integration
1,279 lines (1,272 loc) • 62.3 kB
JavaScript
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 =