x0-react-sdk
Version:
React SDK for X0Pay Hyperlane token bridging with MetaMask and Safe wallet integration
377 lines (324 loc) • 12.6 kB
text/typescript
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';
};