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