UNPKG

x0-react-sdk

Version:

React SDK for X0Pay Hyperlane token bridging with MetaMask and Safe wallet integration

377 lines (324 loc) 12.6 kB
import { ethers } from 'ethers'; import { X0PayConfig, X0PayTransactionParams } from '../types'; // Utility functions for USD and IDR conversions export const convertIdrToSmallestUnits = (idrAmount: number, decimals: number): string => { return (BigInt(Math.floor(idrAmount * (10 ** decimals)))).toString(); }; export const convertSmallestUnitsToIdr = (idrSmallestUnits: string, decimals: number): number => { return Number(idrSmallestUnits) / (10 ** decimals); }; export const convertIdrToUsdSmallestUnits = (idrAmount: number, exchangeRate: number, tokenDecimals: number): string => { const idrSmallestUnits = BigInt(Math.floor(idrAmount * (10 ** tokenDecimals))); const usdSmallestUnits = idrSmallestUnits / BigInt(exchangeRate); return usdSmallestUnits.toString(); }; export const convertUsdToSmallestUnits = (usdAmount: number, tokenDecimals: number): string => { return ethers.utils.parseUnits(usdAmount.toString(), tokenDecimals).toString(); }; export const convertSmallestUnitsToUsd = (usdSmallestUnits: string, tokenDecimals: number): number => { return Number(ethers.utils.formatUnits(usdSmallestUnits, tokenDecimals)); }; // IDR to USD conversion utilities export interface ExchangeRateConfig { idrToUsdRate: number; // Current IDR to USD exchange rate updateInterval?: number; // How often to update the rate (in milliseconds) } // Default exchange rate (can be updated via API) let currentIdrToUsdRate = 0.000065; // Default: 1 IDR = 0.000065 USD (approximately 1 USD = 15,400 IDR) export const setExchangeRate = (rate: number) => { currentIdrToUsdRate = rate; }; export const getExchangeRate = (): number => { return currentIdrToUsdRate; }; export const convertIdrToUsd = (idrAmount: number): number => { return idrAmount * currentIdrToUsdRate; }; export const convertUsdToIdr = (usdAmount: number): number => { return usdAmount / currentIdrToUsdRate; }; // Optional: Fetch exchange rate from an API export const fetchExchangeRate = async (): Promise<number> => { try { // You can replace this with your preferred exchange rate API // Example using a free API (you might want to use a more reliable one in production) const response = await fetch('https://api.exchangerate-api.com/v4/latest/USD'); const data = await response.json(); const idrRate = data.rates.IDR; const usdToIdrRate = 1 / idrRate; // Convert to IDR to USD rate setExchangeRate(usdToIdrRate); return usdToIdrRate; } catch (error) { console.warn('Failed to fetch exchange rate, using default:', error); return currentIdrToUsdRate; } }; // ERC20 ABI for approve function const ERC20_ABI = [ 'function approve(address spender, uint256 amount) external returns (bool)', 'function allowance(address owner, address spender) external view returns (uint256)', 'function balanceOf(address account) external view returns (uint256)', 'function decimals() external view returns (uint8)', 'function symbol() external view returns (string)', 'function name() external view returns (string)', ]; // IHypERC20Collateral ABI for transferRemote function const HYP_COLLATERAL_ABI = [ 'function transferRemote(uint32 destinationDomain, bytes32 recipient, uint256 amount, bytes calldata metadata, address hook) external payable', ]; // Utility function to convert token amount to smallest units export const convertToSmallestUnits = (amount: number, decimals: number): string => { return ethers.utils.parseUnits(amount.toString(), decimals).toString(); }; // Utility function to convert smallest units back to token amount export const convertFromSmallestUnits = (amount: string, decimals: number): string => { return ethers.utils.formatUnits(amount, decimals); }; // Utility function to convert transaction params to smallest units export const convertTransactionParamsToSmallestUnits = ( params: X0PayTransactionParams, decimals: number ): { orderId: string; transferAmount: string; } => { return { orderId: params.orderId, transferAmount: convertToSmallestUnits(params.transferAmount, decimals), }; }; export const buildMetadata = (params: { orderId: string; transferAmount: string; feeAmount: string; exchangeRate: string; // new field, stringified uint256 isInnerFee?: boolean; // optional, defaults to false (outer fee) }): string => { // Encode custom metadata fields const orderIdBytes = ethers.utils.defaultAbiCoder.encode( ['uint256'], [params.orderId] ); const transferAmount = ethers.utils.defaultAbiCoder.encode( ['uint256'], [params.transferAmount] ); const feeAmount = ethers.utils.defaultAbiCoder.encode( ['uint256'], [params.feeAmount] ); const isInnerFee = params.isInnerFee || false; // Default to outer fee const exchangeRate = ethers.utils.defaultAbiCoder.encode( ['uint256'], [params.exchangeRate] ); // Encode custom metadata (129 bytes with fee type) const customMetadata = ethers.utils.concat([ orderIdBytes, transferAmount, feeAmount, isInnerFee ? "0x01" : "0x00", // 1 byte for fee type exchangeRate, ]); // Standard metadata fields const variant = ethers.utils.zeroPad('0x01', 2); // uint16, value = 1 const msgValue = ethers.utils.zeroPad('0x00', 32); // uint256, set to 0 const gasLimit = ethers.utils.zeroPad('0x00', 32); // uint256, set to 0 const refundAddress = ethers.utils.getAddress('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'); // Default refund address const refundAddressBytes = ethers.utils.arrayify(refundAddress); // Encode standard metadata const metadata = ethers.utils.concat([ variant, msgValue, gasLimit, refundAddressBytes, customMetadata, ]); return ethers.utils.hexlify(metadata); }; export const approveToken = async ( signer: ethers.Signer, config: X0PayConfig, amount: string ): Promise<ethers.ContractTransaction> => { const tokenContract = new ethers.Contract( config.erc20TokenAddress, ERC20_ABI, signer ); const tx = await tokenContract.approve(config.hypColAddress, amount); return tx; }; // Helper to round to allowed decimals function roundToDecimals(amount: number, decimals: number): number { return Math.floor(amount * 10 ** decimals) / 10 ** decimals; } // Helper to safely convert to smallest units without exceeding decimals function safeConvertToSmallestUnits(amount: number, decimals: number): string { // Truncate to allowed decimals to avoid "fractional component exceeds decimals" const truncatedAmount = Math.floor(amount * 10 ** decimals) / 10 ** decimals; return ethers.utils.parseUnits(truncatedAmount.toString(), decimals).toString(); } export const transferRemote = async ( signer: ethers.Signer, config: X0PayConfig, params: X0PayTransactionParams, feeCalculation: { feeAmountIdr: number; feeAmountUsd: number }, decimals: number = 6 ): Promise<ethers.ContractTransaction> => { const hypColContract = new ethers.Contract( config.hypColAddress, HYP_COLLATERAL_ABI, signer ); // Use provided exchange rate or fetch it let exchangeRate: number; if (params.exchangeRate && params.exchangeRate > 0) { exchangeRate = params.exchangeRate; } else { // Fallback: fetch exchange rate from API try { const response = await fetch('https://api.exchangerate-api.com/v4/latest/USD'); if (!response.ok) { throw new Error(`Failed to fetch exchange rate: ${response.status} ${response.statusText}`); } const data = await response.json(); if (!data || !data.rates || !data.rates.IDR) { throw new Error('Invalid response from exchange rate API'); } exchangeRate = Math.floor(Number(data.rates.IDR)); if (exchangeRate <= 0) { throw new Error('Invalid exchange rate received'); } } catch (e) { const errorMessage = e instanceof Error ? e.message : 'Failed to fetch exchange rate'; throw new Error(`Exchange rate error: ${errorMessage}`); } } // IDR values from params and fee calculation const transferAmountIdr = params.transferAmount; const feeAmountIdr = feeCalculation.feeAmountIdr; // Convert IDR to USD smallest units using utility function const transferAmountSmallest = convertIdrToUsdSmallestUnits(transferAmountIdr, exchangeRate, decimals); const feeAmountSmallest = convertIdrToUsdSmallestUnits(feeAmountIdr, exchangeRate, decimals); // Convert IDR to smallest units for metadata storage using utility function const transferAmountIdrSmallest = convertIdrToSmallestUnits(transferAmountIdr, decimals); const feeAmountIdrSmallest = convertIdrToSmallestUnits(feeAmountIdr, decimals); // Build metadata with IDR values in smallest units (as stringified uint256) // Always use outer fee (fee added to transfer amount) const metadata = buildMetadata({ orderId: params.orderId, transferAmount: transferAmountIdrSmallest, // IDR in smallest units feeAmount: feeAmountIdrSmallest, // IDR in smallest units isInnerFee: false, // Always outer fee as per the upgrade plan exchangeRate: exchangeRate.toString(), }); // Always use outer fee (transfer amount + fee amount) const totalAmount = ethers.BigNumber.from(transferAmountSmallest) .add(ethers.BigNumber.from(feeAmountSmallest)); const tx = await hypColContract.transferRemote( config.destinationDomain, config.recipient, totalAmount, metadata, config.hookAddress, { value: 0 } // No ETH value needed for this transaction ); return tx; }; export const checkAllowance = async ( provider: ethers.providers.Provider, config: X0PayConfig, ownerAddress: string, amount: string ): Promise<boolean> => { const tokenContract = new ethers.Contract( config.erc20TokenAddress, ERC20_ABI, provider ); const allowance = await tokenContract.allowance(ownerAddress, config.hypColAddress); return allowance.gte(amount); }; export const checkBalance = async ( provider: ethers.providers.Provider, config: X0PayConfig, address: string ): Promise<ethers.BigNumber> => { const tokenContract = new ethers.Contract( config.erc20TokenAddress, ERC20_ABI, provider ); return await tokenContract.balanceOf(address); }; // Helper function to get token info export const getTokenInfo = async ( provider: ethers.providers.Provider, tokenAddress: string ): Promise<{ symbol: string; name: string; decimals: number }> => { const tokenContract = new ethers.Contract( tokenAddress, ERC20_ABI, provider ); const [symbol, name, decimals] = await Promise.all([ tokenContract.symbol(), tokenContract.name(), tokenContract.decimals(), ]); return { symbol, name, decimals }; }; // Helper function to extract clean error messages export const extractErrorReason = (error: any): string => { if (!error) return 'Unknown error occurred'; // If it's already a string, return as is if (typeof error === 'string') return error; // If it's an Error object, check the message if (error instanceof Error) { const message = error.message; // Check for gas estimation errors with reason if (message.includes('UNPREDICTABLE_GAS_LIMIT') && message.includes('reason=')) { const reasonMatch = message.match(/reason="([^"]+)"/); if (reasonMatch) { return reasonMatch[1]; } } // Check for other common error patterns if (message.includes('execution reverted:')) { const revertMatch = message.match(/execution reverted:\s*([^"]+)/); if (revertMatch) { return revertMatch[1].trim(); } } // For MetaMask errors, try to extract the actual error message if (message.includes('Internal JSON-RPC error')) { try { // Try to parse the error data if it's available const errorObj = error as any; if (errorObj.error && errorObj.error.data && errorObj.error.data.message) { return errorObj.error.data.message; } } catch (e) { // If parsing fails, continue to return the original message } } return message; } // If it's an object with error information if (typeof error === 'object') { // Check for MetaMask error structure if (error.error && error.error.data && error.error.data.message) { return error.error.data.message; } // Check for reason property if (error.reason) { return error.reason; } // Check for message property if (error.message) { return error.message; } } return 'Unknown error occurred'; };