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