UNPKG

x0-react-sdk

Version:

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

658 lines (569 loc) 22.9 kB
import React from 'react'; import { ethers } from 'ethers'; import { X0PayConfig, X0PayTransactionParams, TransactionStatus, WalletType, WalletConnectionOptions, FeeCalculation, FeeApiResponse, OrderValidationResponse, SafeProposal, SafeSignedShare, SafeExecutionResult, SafeThresholdInfo, SafeFlowMode } from '../types'; import { approveToken, transferRemote, checkAllowance, checkBalance, convertTransactionParamsToSmallestUnits, extractErrorReason, convertIdrToUsdSmallestUnits, convertSmallestUnitsToUsd } from '../utils/transactions'; import { validateOrderId } from '../utils/orderValidation'; import { connectWallet } from '../connectors'; import { verifySafeOwner, getSafeThresholdAndOwners, prepareSafeProposal as prepareSafeProposalUtil, signSafeProposal as signSafeProposalUtil, executeSafeTransaction as executeSafeTransactionUtil } from '../utils/safe'; // Extend Window interface to include ethereum declare global { interface Window { ethereum?: any; } } export const useX0Pay = ( config: X0PayConfig, transactionParams: X0PayTransactionParams, // IDR units tokenDecimals: number = 6, // Default to 6 decimals for USDC/USDT walletOptions: WalletConnectionOptions = {}, workerApiUrl?: string // Optional worker API URL for fee calculation ) => { const [provider, setProvider] = React.useState<ethers.providers.Web3Provider | null>(null); const [signer, setSigner] = React.useState<ethers.Signer | null>(null); const [address, setAddress] = React.useState<string | null>(null); const [transactionStatus, setTransactionStatus] = React.useState<TransactionStatus>('idle'); const [transactionHash, setTransactionHash] = React.useState<string | null>(null); const [balance, setBalance] = React.useState<ethers.BigNumber | null>(null); const [hasAllowance, setHasAllowance] = React.useState<boolean>(false); const [error, setError] = React.useState<string | null>(null); const [feeCalculation, setFeeCalculation] = React.useState<FeeCalculation | null>(null); const [apiFeeCalculation, setApiFeeCalculation] = React.useState<FeeCalculation | null>(null); const [storedPropsFeeAmount, setStoredPropsFeeAmount] = React.useState<number | null>(null); const [feeValidationError, setFeeValidationError] = React.useState<string | null>(null); const [isLoadingFee, setIsLoadingFee] = React.useState<boolean>(false); const [completeConfig, setCompleteConfig] = React.useState<X0PayConfig | null>(null); const [orderValidation, setOrderValidation] = React.useState<OrderValidationResponse | null>(null); const [isValidatingOrder, setIsValidatingOrder] = React.useState<boolean>(false); // Safe-related state const [currentSafeProposal, setCurrentSafeProposal] = React.useState<SafeProposal | null>(null); const [safeProposalSignatures, setSafeProposalSignatures] = React.useState<SafeSignedShare[]>([]); const [safeStatusMessage, setSafeStatusMessage] = React.useState<string | null>(null); const [isProcessingSafe, setIsProcessingSafe] = React.useState<boolean>(false); const [safeThresholdInfo, setSafeThresholdInfo] = React.useState<SafeThresholdInfo | null>(null); // Fetch fee calculation from worker API const fetchFeeCalculation = React.useCallback(async () => { if (!workerApiUrl || !transactionParams.transferAmount) return; try { setIsLoadingFee(true); const response = await fetch( `${workerApiUrl}/api/fee?amount=${transactionParams.transferAmount}&exchangeRate=${transactionParams.exchangeRate || 15000}` ); if (!response.ok) { throw new Error('Failed to fetch fee calculation'); } const result: FeeApiResponse = await response.json(); if (result.success && result.data) { setApiFeeCalculation(result.data); // If fee amount is provided in transaction params, validate it against API fee if (transactionParams.feeAmount !== undefined && transactionParams.feeAmount !== null) { setStoredPropsFeeAmount(transactionParams.feeAmount); if (transactionParams.feeAmount < result.data.feeAmountIdr) { setFeeValidationError(`Fee amount (${transactionParams.feeAmount} IDR) is less than required API fee (${result.data.feeAmountIdr} IDR)`); setFeeCalculation(null); } else { setFeeValidationError(null); // Create a modified fee calculation using transaction params fee amount const modifiedFeeCalculation = { ...result.data, feeAmountIdr: transactionParams.feeAmount, feeAmountUsd: transactionParams.feeAmount / (transactionParams.exchangeRate || 15000) }; setFeeCalculation(modifiedFeeCalculation); } } else { // No fee amount provided, use API fee setFeeCalculation(result.data); setFeeValidationError(null); setStoredPropsFeeAmount(null); } } else { throw new Error(result.error || 'Failed to calculate fee'); } } catch (err) { setError(extractErrorReason(err)); } finally { setIsLoadingFee(false); } }, [workerApiUrl, transactionParams.transferAmount, transactionParams.exchangeRate, transactionParams.feeAmount]); // Fetch fee when transaction params change React.useEffect(() => { fetchFeeCalculation(); }, [fetchFeeCalculation]); // Handle fee amount changes from transaction params React.useEffect(() => { if (transactionParams.feeAmount !== undefined && transactionParams.feeAmount !== null && apiFeeCalculation) { setStoredPropsFeeAmount(transactionParams.feeAmount); // Validate fee amount against API fee if (transactionParams.feeAmount < apiFeeCalculation.feeAmountIdr) { setFeeValidationError(`Fee amount (${transactionParams.feeAmount} IDR) is less than required API fee (${apiFeeCalculation.feeAmountIdr} IDR)`); setFeeCalculation(null); } else { setFeeValidationError(null); // Create a modified fee calculation using transaction params fee amount const modifiedFeeCalculation = { ...apiFeeCalculation, feeAmountIdr: transactionParams.feeAmount, feeAmountUsd: transactionParams.feeAmount / (transactionParams.exchangeRate || 15000) }; setFeeCalculation(modifiedFeeCalculation); } } else if (transactionParams.feeAmount !== undefined && transactionParams.feeAmount !== null && !apiFeeCalculation) { // Fee amount provided but no API fee yet, store it for later validation setStoredPropsFeeAmount(transactionParams.feeAmount); setFeeValidationError(null); } else if ((transactionParams.feeAmount === undefined || transactionParams.feeAmount === null) && apiFeeCalculation) { // No fee amount provided, use API fee setFeeCalculation(apiFeeCalculation); setFeeValidationError(null); setStoredPropsFeeAmount(null); } }, [transactionParams.feeAmount, apiFeeCalculation, transactionParams.exchangeRate]); // Validate order ID when transaction params change const validateOrderIdHook = React.useCallback(async () => { if (!workerApiUrl || !transactionParams.orderId) { setOrderValidation(null); return; } try { setIsValidatingOrder(true); const result = await validateOrderId(workerApiUrl, transactionParams.orderId); setOrderValidation(result); } catch (err) { setOrderValidation({ success: false, error: extractErrorReason(err) }); } finally { setIsValidatingOrder(false); } }, [workerApiUrl, transactionParams.orderId]); // Validate order ID when order ID changes React.useEffect(() => { validateOrderIdHook(); }, [validateOrderIdHook]); // Retrieve recipient from hook contract when provider is available React.useEffect(() => { const getRecipientFromHook = async () => { if (!provider || !config.hookAddress) return; try { // If config already has recipient, use it if (config.recipient) { setCompleteConfig(config); return; } // ABI for the vaultAddress function const hookABI = [ "function vaultAddress() external view returns (address)" ]; const hookContract = new ethers.Contract(config.hookAddress, hookABI, provider); const recipient = await hookContract.vaultAddress(); // Convert address to bytes32 format for Hyperlane message const recipientBytes = ethers.utils.hexZeroPad(recipient, 32); setCompleteConfig({ ...config, recipient: recipientBytes, }); } catch (error) { setError(`Failed to get recipient from hook contract: ${error}`); } }; getRecipientFromHook(); }, [provider, config]); const connectWalletHook = React.useCallback(async (walletType?: WalletType) => { try { setError(null); const connectionOptions = { ...walletOptions, walletType: walletType || walletOptions.walletType }; const result = await connectWallet(connectionOptions); setProvider(result.provider); setSigner(result.signer); setAddress(result.address); return result; } catch (err) { const errorMessage = extractErrorReason(err); setError(errorMessage); throw new Error(errorMessage); } }, [walletOptions]); // Backward compatibility - keep the old function name const connectToMetaMask = React.useCallback(async () => { return connectWalletHook('metamask'); }, [connectWalletHook]); // Listen for account changes React.useEffect(() => { if (!window.ethereum) return; const handleAccountsChanged = (accounts: string[]) => { if (accounts.length === 0) { // MetaMask is locked or the user has no accounts setAddress(null); setProvider(null); setSigner(null); setBalance(null); setHasAllowance(false); } else if (accounts[0] !== address) { // Account changed setAddress(accounts[0]); if (provider) { const newSigner = provider.getSigner(); setSigner(newSigner); } } }; const handleChainChanged = () => { // Reload the page when chain changes to ensure everything is in sync window.location.reload(); }; window.ethereum.on('accountsChanged', handleAccountsChanged); window.ethereum.on('chainChanged', handleChainChanged); return () => { window.ethereum.removeListener('accountsChanged', handleAccountsChanged); window.ethereum.removeListener('chainChanged', handleChainChanged); }; }, [provider, address]); // Check balance and allowance when wallet is connected React.useEffect(() => { const checkBalanceAndAllowance = async () => { if (!provider || !address || !feeCalculation || !completeConfig) return; try { // Convert IDR amounts to USD smallest units const transferAmountUsd = transactionParams.transferAmount / (transactionParams.exchangeRate || 15000); const feeAmountUsd = feeCalculation.feeAmountUsd; // Convert to smallest units (e.g., wei for USDT) const transferAmountSmallestUnits = Math.floor(transferAmountUsd * Math.pow(10, tokenDecimals)); const feeAmountSmallestUnits = Math.floor(feeAmountUsd * Math.pow(10, tokenDecimals)); // Total amount for approval (transfer + fee) const totalAmount = ethers.BigNumber.from(transferAmountSmallestUnits) .add(ethers.BigNumber.from(feeAmountSmallestUnits)); const [balanceResult, allowanceResult] = await Promise.all([ checkBalance(provider, completeConfig, address), checkAllowance(provider, completeConfig, address, totalAmount.toString()) ]); setBalance(balanceResult); setHasAllowance(allowanceResult); } catch (err) { setError(extractErrorReason(err)); } }; checkBalanceAndAllowance(); }, [provider, address, completeConfig, feeCalculation, transactionParams, tokenDecimals]); const approve = React.useCallback(async () => { if (!signer) { setError('Please connect to MetaMask first'); return; } if (!feeCalculation) { setError('Fee calculation not available'); return; } if (!completeConfig) { setError('Configuration not complete - recipient not available'); return; } try { setTransactionStatus('approving'); setError(null); // Convert IDR amounts to USD smallest units const transferAmountUsd = transactionParams.transferAmount / (transactionParams.exchangeRate || 15000); const feeAmountUsd = feeCalculation.feeAmountUsd; // Convert to smallest units (e.g., wei for USDT) const transferAmountSmallestUnits = Math.floor(transferAmountUsd * Math.pow(10, tokenDecimals)); const feeAmountSmallestUnits = Math.floor(feeAmountUsd * Math.pow(10, tokenDecimals)); // Total amount for approval (transfer + fee) const totalAmount = ethers.BigNumber.from(transferAmountSmallestUnits) .add(ethers.BigNumber.from(feeAmountSmallestUnits)); const tx = await approveToken(signer, completeConfig, totalAmount.toString()); await tx.wait(); setTransactionStatus('approved'); setHasAllowance(true); } catch (err) { setTransactionStatus('error'); setError(extractErrorReason(err)); } }, [signer, completeConfig, feeCalculation, transactionParams, tokenDecimals]); const transfer = React.useCallback(async () => { if (!signer) { setError('Please connect to MetaMask first'); return; } if (!hasAllowance) { setError('Token not approved'); return; } if (!feeCalculation) { setError('Fee calculation not available'); return; } if (!completeConfig) { setError('Configuration not complete - recipient not available'); return; } // Validate order ID (required) if (!transactionParams.orderId) { setError('Order ID is required'); return; } if (workerApiUrl) { if (orderValidation && orderValidation.success && orderValidation.data?.exists) { setError('Order ID already exists and cannot be used again'); return; } if (isValidatingOrder) { setError('Please wait for order ID validation to complete'); return; } if (orderValidation && !orderValidation.success) { setError(`Order ID validation failed: ${orderValidation.error}`); return; } } try { setTransactionStatus('transferring'); setError(null); setTransactionHash(null); // Pass the original transactionParams (IDR values) and fee calculation to transferRemote const tx = await transferRemote(signer, completeConfig, transactionParams, feeCalculation, tokenDecimals); setTransactionHash(tx.hash); await tx.wait(); setTransactionStatus('completed'); } catch (err) { setTransactionStatus('error'); setError(extractErrorReason(err)); } }, [signer, completeConfig, transactionParams, tokenDecimals, hasAllowance, feeCalculation, orderValidation, isValidatingOrder, workerApiUrl]); const reset = React.useCallback(() => { setTransactionStatus('idle'); setError(null); setCurrentSafeProposal(null); setSafeProposalSignatures([]); setSafeStatusMessage(null); setIsProcessingSafe(false); setSafeThresholdInfo(null); }, []); // Safe helper functions const prepareSafeProposal = React.useCallback(async ({ safeAddress, creator, tokenAddress, destinationDomain, toBytes, metadata = {} }: { safeAddress: string; creator: string; tokenAddress: string; destinationDomain: number; toBytes: string; metadata?: any; }): Promise<SafeProposal> => { if (!signer || !provider || !completeConfig || !feeCalculation) { throw new Error('Wallet not connected, config not ready, or fee calculation not available'); } try { setIsProcessingSafe(true); setSafeStatusMessage(null); // Verify creator is owner of Safe and get threshold info const isOwner = await verifySafeOwner(safeAddress, creator, provider); if (!isOwner) { throw new Error('Connected account is not an owner of this Safe'); } // Get Safe threshold and owners information const thresholdInfo = await getSafeThresholdAndOwners(safeAddress, provider); setSafeThresholdInfo(thresholdInfo); // Prepare proposal with actual collateral address and proper amount calculation const proposal = await prepareSafeProposalUtil({ safeAddress, creator, tokenAddress, transferAmountIdr: transactionParams.transferAmount, feeAmountIdr: feeCalculation.feeAmountIdr, exchangeRate: transactionParams.exchangeRate || 15000, destinationDomain, toBytes, orderId: transactionParams.orderId || '', metadata: metadata, signer, provider, collateralAddress: completeConfig.hypColAddress, decimals: tokenDecimals }); setCurrentSafeProposal(proposal); setSafeProposalSignatures(proposal.signatures || []); setSafeStatusMessage('Proposal prepared. Share this JSON with other owners to collect signatures.'); return proposal; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setSafeStatusMessage(errorMessage); throw err; } finally { setIsProcessingSafe(false); } }, [signer, provider, completeConfig, transactionParams, feeCalculation, tokenDecimals]); const signSafeProposal = React.useCallback(async ( proposal: SafeProposal, signerAddress: string ): Promise<SafeSignedShare> => { if (!signer || !provider) { throw new Error('Wallet not connected'); } try { setIsProcessingSafe(true); setSafeStatusMessage(null); // Verify signer is owner of Safe and get threshold info const isOwner = await verifySafeOwner(proposal.safeAddress, signerAddress, provider); if (!isOwner) { throw new Error('Connected account is not an owner of this Safe'); } // Get Safe threshold and owners information const thresholdInfo = await getSafeThresholdAndOwners(proposal.safeAddress, provider); setSafeThresholdInfo(thresholdInfo); // Check if already signed const alreadySigned = proposal.signatures?.some(sig => sig.signer.toLowerCase() === signerAddress.toLowerCase() ); if (alreadySigned) { throw new Error('You have already signed this proposal'); } const share = await signSafeProposalUtil(proposal, signer, provider); // Update proposal with new signature const updatedSignatures = [...(proposal.signatures || []), share]; const updatedProposal = { ...proposal, signatures: updatedSignatures }; setCurrentSafeProposal(updatedProposal); setSafeProposalSignatures(updatedSignatures); setSafeStatusMessage('Signed proposal successfully. Share the updated JSON with other owners if needed.'); return share; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setSafeStatusMessage(errorMessage); throw err; } finally { setIsProcessingSafe(false); } }, [signer, provider]); const executeSafeTransaction = React.useCallback(async (): Promise<SafeExecutionResult> => { if (!currentSafeProposal || !signer || !provider) { throw new Error('No proposal available or wallet not connected'); } try { setIsProcessingSafe(true); setSafeStatusMessage(null); // Check threshold const { threshold } = await getSafeThresholdAndOwners(currentSafeProposal.safeAddress, provider); const uniqueOwners = new Set(safeProposalSignatures.map(s => s.signer.toLowerCase())); if (uniqueOwners.size < threshold) { const errorMsg = `Not enough signatures yet. ${uniqueOwners.size}/${threshold} collected.`; setSafeStatusMessage(errorMsg); throw new Error(errorMsg); } const result = await executeSafeTransactionUtil({ proposal: currentSafeProposal, signatures: safeProposalSignatures, signer, provider }); if (result.success && result.txHash) { setSafeStatusMessage(`Transaction submitted: ${result.txHash}`); setTransactionHash(result.txHash); setTransactionStatus('completed'); } else { const errorMsg = result.error || 'Transaction execution failed'; setSafeStatusMessage(errorMsg); throw new Error(errorMsg); } return result; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setSafeStatusMessage(errorMessage); throw err; } finally { setIsProcessingSafe(false); } }, [currentSafeProposal, safeProposalSignatures, signer, provider]); const verifySafeOwnerHook = React.useCallback(async ( safeAddress: string, account: string ): Promise<boolean> => { if (!provider) { throw new Error('Provider not available'); } return verifySafeOwner(safeAddress, account, provider); }, [provider]); const getSafeThresholdAndOwnersHook = React.useCallback(async ( safeAddress: string ): Promise<SafeThresholdInfo> => { if (!provider) { throw new Error('Provider not available'); } return getSafeThresholdAndOwners(safeAddress, provider); }, [provider]); return { // State provider, signer, address, transactionStatus, transactionHash, balance, hasAllowance, error, feeCalculation, apiFeeCalculation, propsFeeAmount: storedPropsFeeAmount, feeValidationError, isLoadingFee, orderValidation, isValidatingOrder, completeConfig, // Safe state currentSafeProposal, safeProposalSignatures, safeStatusMessage, setSafeStatusMessage, isProcessingSafe, safeThresholdInfo, // Actions connectWallet: connectWalletHook, connectToMetaMask, approve, transfer, reset, fetchFeeCalculation, validateOrderId: validateOrderIdHook, // Safe actions prepareSafeProposal, signSafeProposal, executeSafeTransaction, verifySafeOwner: verifySafeOwnerHook, getSafeThresholdAndOwners: getSafeThresholdAndOwnersHook, }; };