UNPKG

x0-react-sdk

Version:

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

566 lines (507 loc) 17.4 kB
import { ethers } from 'ethers'; import { SafeProposal, SafeSignedShare, SafeExecutionResult, SafeThresholdInfo } from '../types'; import { convertIdrToUsdSmallestUnits, convertIdrToSmallestUnits, buildMetadata } from './transactions'; /** * Safe utility functions for building transactions and handling signatures * This module provides low-level Safe contract interaction without requiring the Safe SDK */ // Safe contract ABI for basic operations const SAFE_ABI = [ 'function getOwners() view returns (address[])', 'function getThreshold() view returns (uint256)', 'function nonce() view returns (uint256)', 'function getTransactionHash(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 _nonce) public view returns (bytes32)', 'function execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures) returns (bool success)', 'function domainSeparator() view returns (bytes32)', 'function getChainId() view returns (uint256)' ]; /** * Verify if an account is an owner of the Safe */ export async function verifySafeOwner( safeAddress: string, account: string, provider: ethers.providers.Provider ): Promise<boolean> { try { const safe = new ethers.Contract(safeAddress, SAFE_ABI, provider); const owners: string[] = await safe.getOwners(); return owners.map(o => o.toLowerCase()).includes(account.toLowerCase()); } catch (err) { console.warn('verifySafeOwner failed', err); return false; } } /** * Get Safe threshold and owners information */ export async function getSafeThresholdAndOwners( safeAddress: string, provider: ethers.providers.Provider ): Promise<SafeThresholdInfo> { const safe = new ethers.Contract(safeAddress, SAFE_ABI, provider); const [owners, thresholdBn] = await Promise.all([ safe.getOwners(), safe.getThreshold() ]); return { threshold: thresholdBn.toNumber(), owners: owners.map((o: string) => o.toLowerCase()) }; } /** * Build approve and transfer transactions for Safe execution * Uses the same amount calculation logic as the EOA flow */ export async function buildApproveAndTransferTxs({ tokenAddress, approvedSpender, transferAmountIdr, feeAmountIdr, exchangeRate, destinationDomain, toBytes, orderId, decimals = 6 }: { tokenAddress: string; approvedSpender: string; transferAmountIdr: number; feeAmountIdr: number; exchangeRate: number; destinationDomain: number; toBytes: string; orderId: string; decimals?: number; }): Promise<{ txs: Array<{ to: string; value: string; data: string }>; displayMetadata: any }> { // Validate inputs if (!tokenAddress || !approvedSpender || !transferAmountIdr || !feeAmountIdr || !exchangeRate) { throw new Error('Missing required parameters for transaction building'); } // Convert IDR to USD smallest units using the same logic as EOA flow const transferAmountSmallest = convertIdrToUsdSmallestUnits(transferAmountIdr, exchangeRate, decimals); const feeAmountSmallest = convertIdrToUsdSmallestUnits(feeAmountIdr, exchangeRate, decimals); // Convert IDR to smallest units for metadata storage const transferAmountIdrSmallest = convertIdrToSmallestUnits(transferAmountIdr, decimals); const feeAmountIdrSmallest = convertIdrToSmallestUnits(feeAmountIdr, decimals); // Always use outer fee (transfer amount + fee amount) - same as EOA flow const totalAmount = ethers.BigNumber.from(transferAmountSmallest) .add(ethers.BigNumber.from(feeAmountSmallest)); // Build metadata with IDR values in smallest units (same as EOA flow) const metadata = buildMetadata({ 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(), }); // Create human-readable metadata for display purposes const displayMetadata = { orderId, transferAmountIdr, feeAmountIdr, exchangeRate, transferAmountUsd: Number(transferAmountSmallest) / Math.pow(10, decimals), feeAmountUsd: Number(feeAmountSmallest) / Math.pow(10, decimals), totalAmountUsd: Number(totalAmount.toString()) / Math.pow(10, decimals), tokenDecimals: decimals, isInnerFee: false }; console.log('Safe transaction amount calculation:', JSON.stringify({ transferAmountIdr, feeAmountIdr, exchangeRate, transferAmountSmallest, feeAmountSmallest, totalAmount: totalAmount.toString() }, null, 2)); // ERC20 approve const erc20Iface = new ethers.utils.Interface(['function approve(address spender, uint256 amount)']); let approveCalldata; try { approveCalldata = erc20Iface.encodeFunctionData('approve', [approvedSpender, totalAmount]); console.log('Approve calldata created for amount:', totalAmount.toString()); } catch (error: any) { console.error('Error encoding approve function:', error); throw new Error(`Failed to encode approve function: ${error.message}`); } // transferRemote (HypERC20Collateral) - use the correct ABI const collateralIface = new ethers.utils.Interface([ 'function transferRemote(uint32 destinationDomain, bytes32 recipient, uint256 amount, bytes calldata metadata, address hook) external payable' ]); console.log('Building transferRemote transaction:', JSON.stringify({ destinationDomain, toBytes, totalAmount: totalAmount.toString(), metadata }, null, 2)); const transferCalldata = collateralIface.encodeFunctionData('transferRemote', [ destinationDomain, toBytes, totalAmount, metadata, ethers.constants.AddressZero, // hook address ]); return { txs: [ { to: tokenAddress, value: '0', data: approveCalldata }, { to: approvedSpender, value: '0', data: transferCalldata }, ], displayMetadata }; } /** * Create canonical proposal hash for signing * For production, consider using EIP-712 typed data instead */ export function createCanonicalProposalHash(payload: { safeAddress: string; txs: Array<{ to: string; value: string; data: string }>; metadata?: any; }): string { const json = JSON.stringify({ safeAddress: payload.safeAddress.toLowerCase(), txs: payload.txs, metadata: payload.metadata || {}, }); return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(json)); } /** * Build Safe transaction hash (same as Safe contract would) */ export async function buildSafeTxHash( safeAddress: string, tx: { to: string; value: string; data: string; operation: number }, provider: ethers.providers.Provider, nonce: number ): Promise<string> { const safe = new ethers.Contract(safeAddress, SAFE_ABI, provider); const safeTxHash = await safe.getTransactionHash( tx.to, tx.value, tx.data, tx.operation, 0, // safeTxGas 0, // baseGas 0, // gasPrice ethers.constants.AddressZero, // gasToken ethers.constants.AddressZero, // refundReceiver nonce ); return safeTxHash; } /** * Prepare a Safe proposal with initial signature from creator */ export async function prepareSafeProposal({ safeAddress, creator, tokenAddress, transferAmountIdr, feeAmountIdr, exchangeRate, destinationDomain, toBytes, orderId, metadata = {}, signer, provider, collateralAddress, decimals = 6 }: { safeAddress: string; creator: string; tokenAddress: string; transferAmountIdr: number; feeAmountIdr: number; exchangeRate: number; destinationDomain: number; toBytes: string; orderId: string; metadata?: any; signer: ethers.Signer; provider: ethers.providers.Provider; collateralAddress: string; decimals?: number; }): Promise<SafeProposal> { // Validate inputs if (!safeAddress || !creator || !tokenAddress || !transferAmountIdr || !feeAmountIdr || !exchangeRate || !collateralAddress) { throw new Error('Missing required parameters for Safe proposal'); } // Build transactions using the same logic as EOA flow const { txs, displayMetadata } = await buildApproveAndTransferTxs({ tokenAddress, approvedSpender: collateralAddress, transferAmountIdr, feeAmountIdr, exchangeRate, destinationDomain, toBytes, orderId, decimals }); // Get current nonce for Safe transaction hash const safe = new ethers.Contract(safeAddress, SAFE_ABI, provider); const nonce = await safe.nonce(); // Use the transferRemote transaction for signing const primaryTx = txs[txs.length - 1]; // transferRemote transaction const safeTx = { to: primaryTx.to, value: primaryTx.value || '0', data: primaryTx.data, operation: 0, // CALL }; // Build the actual Safe transaction hash that will be used for execution const safeTxHash = await buildSafeTxHash(safeAddress, safeTx, provider, nonce.toNumber()); console.log('Safe transaction hash to sign:', safeTxHash); console.log('Safe transaction details:', JSON.stringify({ to: safeTx.to, value: safeTx.value, data: safeTx.data, operation: safeTx.operation, nonce: nonce.toNumber() }, null, 2)); // Sign using EIP-712 typed data (eth_signTypedData) // This is the proper way to sign Safe transactions const signerAddress = (await signer.getAddress()).toLowerCase(); const chainId = (await provider.getNetwork()).chainId; const signature = await (signer as any)._signTypedData( { chainId: chainId, verifyingContract: safeAddress }, { SafeTx: [ { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'data', type: 'bytes' }, { name: 'operation', type: 'uint8' }, { name: 'safeTxGas', type: 'uint256' }, { name: 'baseGas', type: 'uint256' }, { name: 'gasPrice', type: 'uint256' }, { name: 'gasToken', type: 'address' }, { name: 'refundReceiver', type: 'address' }, { name: 'nonce', type: 'uint256' } ] }, { to: safeTx.to, value: safeTx.value, data: safeTx.data, operation: safeTx.operation, safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: ethers.constants.AddressZero, refundReceiver: ethers.constants.AddressZero, nonce: nonce.toNumber() } ); const share: SafeSignedShare = { signer: signerAddress, signature, timestamp: Date.now() }; return { safeAddress, creator, targetContract: collateralAddress, // Use actual collateral address data: JSON.stringify(txs), metadata: { rawMetadata: metadata, // The encoded metadata for the transaction displayMetadata // Include human-readable amounts }, signatures: [share], }; } /** * Sign an existing Safe proposal */ export async function signSafeProposal( proposal: SafeProposal, signer: ethers.Signer, provider: ethers.providers.Provider ): Promise<SafeSignedShare> { if (!proposal) throw new Error('Invalid proposal'); // Get current nonce for Safe transaction hash const safe = new ethers.Contract(proposal.safeAddress, SAFE_ABI, provider); const nonce = await safe.nonce(); // Use the transferRemote transaction for signing const txs = JSON.parse(proposal.data); const primaryTx = txs[txs.length - 1]; // transferRemote transaction const safeTx = { to: primaryTx.to, value: primaryTx.value || '0', data: primaryTx.data, operation: 0, // CALL }; // Build the actual Safe transaction hash that will be used for execution const safeTxHash = await buildSafeTxHash(proposal.safeAddress, safeTx, provider, nonce.toNumber()); console.log('Safe transaction hash to sign (join):', safeTxHash); console.log('Safe transaction details (join):', JSON.stringify({ to: safeTx.to, value: safeTx.value, data: safeTx.data, operation: safeTx.operation, nonce: nonce.toNumber() }, null, 2)); // Sign using EIP-712 typed data (eth_signTypedData) // This is the proper way to sign Safe transactions const chainId = (await provider.getNetwork()).chainId; const signature = await (signer as any)._signTypedData( { chainId: chainId, verifyingContract: proposal.safeAddress }, { SafeTx: [ { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'data', type: 'bytes' }, { name: 'operation', type: 'uint8' }, { name: 'safeTxGas', type: 'uint256' }, { name: 'baseGas', type: 'uint256' }, { name: 'gasPrice', type: 'uint256' }, { name: 'gasToken', type: 'address' }, { name: 'refundReceiver', type: 'address' }, { name: 'nonce', type: 'uint256' } ] }, { to: safeTx.to, value: safeTx.value, data: safeTx.data, operation: safeTx.operation, safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: ethers.constants.AddressZero, refundReceiver: ethers.constants.AddressZero, nonce: nonce.toNumber() } ); const signerAddress = (await signer.getAddress()).toLowerCase(); return { signer: signerAddress, signature, timestamp: Date.now() }; } /** * Build raw transaction for Safe execution (for MetaMask submission) */ export async function buildSafeExecutionTx({ proposal, signatures, provider }: { proposal: SafeProposal; signatures: SafeSignedShare[]; provider: ethers.providers.Provider; }): Promise<{ to: string; data: string; value: number }> { const safeAddress = proposal.safeAddress; const safe = new ethers.Contract(safeAddress, SAFE_ABI, provider); // Get current nonce const nonce = await safe.nonce(); // Parse transactions and use the transfer transaction (last one) const txs = JSON.parse(proposal.data); const primaryTx = txs[txs.length - 1]; // Use transferRemote transaction const safeTx = { to: primaryTx.to, value: primaryTx.value || '0', data: primaryTx.data, operation: 0, // CALL }; // Build Safe transaction hash const safeTxHash = await buildSafeTxHash(safeAddress, safeTx, provider, nonce.toNumber()); // Sort signatures by owner address (ascending order) - required by Safe contract const sortedSignatures = [...signatures].sort((a, b) => { const addrA = ethers.utils.getAddress(a.signer); const addrB = ethers.utils.getAddress(b.signer); return addrA.toLowerCase().localeCompare(addrB.toLowerCase()); }); console.log('Sorted signatures by owner address:', JSON.stringify(sortedSignatures.map(s => ({ signer: s.signer, address: ethers.utils.getAddress(s.signer) })), null, 2)); // Verify that all signers are actual Safe owners const { owners } = await getSafeThresholdAndOwners(safeAddress, provider); const ownerSet = new Set(owners.map(o => o.toLowerCase())); for (const sig of sortedSignatures) { const signerAddress = ethers.utils.getAddress(sig.signer).toLowerCase(); if (!ownerSet.has(signerAddress)) { throw new Error(`Signer ${sig.signer} is not a Safe owner`); } } // Collect and pack signatures const sigs: string[] = []; for (const sig of sortedSignatures) { const { r, s, v } = ethers.utils.splitSignature(sig.signature); // Each signature is 65 bytes: r (32) + s (32) + v (1) sigs.push(ethers.utils.hexConcat([r, s, ethers.utils.hexlify(v)])); } const signaturesBytes = ethers.utils.hexConcat(sigs); // Encode execTransaction call const safeIface = new ethers.utils.Interface(SAFE_ABI); const data = safeIface.encodeFunctionData('execTransaction', [ safeTx.to, safeTx.value, safeTx.data, safeTx.operation, 0, // safeTxGas 0, // baseGas 0, // gasPrice ethers.constants.AddressZero, // gasToken ethers.constants.AddressZero, // refundReceiver signaturesBytes, ]); return { to: safeAddress, data, value: 0, }; } /** * Execute Safe transaction by building and submitting via signer */ export async function executeSafeTransaction({ proposal, signatures, signer, provider }: { proposal: SafeProposal; signatures: SafeSignedShare[]; signer: ethers.Signer; provider: ethers.providers.Provider; }): Promise<SafeExecutionResult> { try { // Validate threshold const { threshold, owners } = await getSafeThresholdAndOwners(proposal.safeAddress, provider); const uniqueSigs = new Map<string, SafeSignedShare>(); signatures.forEach(s => { uniqueSigs.set(s.signer.toLowerCase(), s); }); if (uniqueSigs.size < threshold) { throw new Error(`Not enough signatures: got ${uniqueSigs.size}, need ${threshold}`); } // Build transaction const txReq = await buildSafeExecutionTx({ proposal, signatures, provider }); // Submit via signer (MetaMask) const tx = await signer.sendTransaction(txReq); await tx.wait(); return { txHash: tx.hash, success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }