feat: comprehensive project improvements and fixes

- Fix all TypeScript compilation errors (40+ fixes)
  - Add missing type definitions (TransactionRequest, SafeInfo)
  - Fix TransactionRequestStatus vs TransactionStatus confusion
  - Fix import paths and provider type issues
  - Fix test file errors and mock providers

- Implement comprehensive security features
  - AES-GCM encryption with PBKDF2 key derivation
  - Input validation and sanitization
  - Rate limiting and nonce management
  - Replay attack prevention
  - Access control and authorization

- Add comprehensive test suite
  - Integration tests for transaction flow
  - Security validation tests
  - Wallet management tests
  - Encryption and rate limiter tests
  - E2E tests with Playwright

- Add extensive documentation
  - 12 numbered guides (setup, development, API, security, etc.)
  - Security documentation and audit reports
  - Code review and testing reports
  - Project organization documentation

- Update dependencies
  - Update axios to latest version (security fix)
  - Update React types to v18
  - Fix peer dependency warnings

- Add development tooling
  - CI/CD workflows (GitHub Actions)
  - Pre-commit hooks (Husky)
  - Linting and formatting (Prettier, ESLint)
  - Security audit workflow
  - Performance benchmarking

- Reorganize project structure
  - Move reports to docs/reports/
  - Clean up root directory
  - Organize documentation

- Add new features
  - Smart wallet management (Gnosis Safe, ERC4337)
  - Transaction execution and approval workflows
  - Balance management and token support
  - Error boundary and monitoring (Sentry)

- Fix WalletConnect configuration
  - Handle missing projectId gracefully
  - Add environment variable template
This commit is contained in:
defiQUG
2026-01-14 02:17:26 -08:00
parent cdde90c128
commit 55fe7d10eb
107 changed files with 25987 additions and 866 deletions

152
helpers/balance/index.ts Normal file
View File

@@ -0,0 +1,152 @@
import { providers, Contract, utils, ethers } from "ethers";
import { TokenBalance, WalletBalance } from "../../types";
const ERC20_ABI = [
"function balanceOf(address owner) view returns (uint256)",
"function decimals() view returns (uint8)",
"function symbol() view returns (string)",
"function name() view returns (string)",
];
const COMMON_TOKENS: Record<number, Array<{ address: string; symbol: string; name: string; decimals: number }>> = {
1: [
{
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
symbol: "USDT",
name: "Tether USD",
decimals: 6,
},
{
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0c3606eB48",
symbol: "USDC",
name: "USD Coin",
decimals: 6,
},
{
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
name: "Dai Stablecoin",
decimals: 18,
},
],
137: [
{
address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
symbol: "USDT",
name: "Tether USD",
decimals: 6,
},
{
address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
symbol: "USDC",
name: "USD Coin",
decimals: 6,
},
],
42161: [
{
address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
symbol: "USDT",
name: "Tether USD",
decimals: 6,
},
{
address: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8",
symbol: "USDC",
name: "USD Coin",
decimals: 6,
},
],
};
export async function getNativeBalance(
address: string,
provider: providers.Provider
): Promise<string> {
try {
const balance = await provider.getBalance(address);
return balance.toString();
} catch (error) {
console.error("Failed to get native balance", error);
return "0";
}
}
export async function getTokenBalance(
tokenAddress: string,
walletAddress: string,
provider: providers.Provider
): Promise<TokenBalance | null> {
try {
// Validate addresses
if (!utils.isAddress(tokenAddress) || !utils.isAddress(walletAddress)) {
throw new Error("Invalid address");
}
const checksummedTokenAddress = utils.getAddress(tokenAddress);
const checksummedWalletAddress = utils.getAddress(walletAddress);
const tokenContract = new Contract(checksummedTokenAddress, ERC20_ABI, provider);
// Add timeout to prevent hanging
const { SECURITY } = await import("@/utils/constants");
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Token balance fetch timeout")), SECURITY.TOKEN_BALANCE_TIMEOUT_MS)
);
const [balance, decimals, symbol, name] = await Promise.race([
Promise.all([
tokenContract.balanceOf(checksummedWalletAddress),
tokenContract.decimals(),
tokenContract.symbol(),
tokenContract.name(),
]),
timeoutPromise,
]) as [any, number, string, string];
// Validate decimals
const { VALIDATION } = await import("@/utils/constants");
if (decimals < VALIDATION.TOKEN_DECIMALS_MIN || decimals > VALIDATION.TOKEN_DECIMALS_MAX) {
throw new Error(`Invalid token decimals: ${decimals}`);
}
const balanceFormatted = ethers.utils.formatUnits(balance, decimals);
return {
tokenAddress: checksummedTokenAddress,
symbol: symbol || "UNKNOWN",
name: name || "Unknown Token",
decimals,
balance: balance.toString(),
balanceFormatted,
};
} catch (error: any) {
console.error(`Failed to get token balance for ${tokenAddress}`, error);
return null;
}
}
export async function getWalletBalance(
address: string,
networkId: number,
provider: providers.Provider,
tokenAddresses?: string[]
): Promise<WalletBalance> {
// Get native balance
const nativeBalance = await getNativeBalance(address, provider);
const nativeFormatted = ethers.utils.formatEther(nativeBalance);
// Get token balances
const tokensToCheck = tokenAddresses || COMMON_TOKENS[networkId]?.map((t) => t.address) || [];
const tokenBalances = await Promise.all(
tokensToCheck.map((tokenAddress) => getTokenBalance(tokenAddress, address, provider))
);
const validTokenBalances = tokenBalances.filter((tb): tb is TokenBalance => tb !== null);
return {
native: nativeBalance,
nativeFormatted,
tokens: validTokenBalances,
};
}

View File

@@ -8,6 +8,7 @@ import {
RequestId,
} from "../types";
import { getSDKVersion } from "./utils";
import { SECURITY } from "../utils/constants";
type MessageHandler = (
msg: SDKMessageEvent
@@ -26,11 +27,35 @@ type SDKMethods = Methods | LegacyMethods;
class AppCommunicator {
private iframeRef: MutableRefObject<HTMLIFrameElement | null>;
private handlers = new Map<SDKMethods, MessageHandler>();
private messageTimestamps = new Map<string, number>();
private allowedOrigins: string[] = [];
private cleanupInterval?: NodeJS.Timeout;
constructor(iframeRef: MutableRefObject<HTMLIFrameElement | null>) {
this.iframeRef = iframeRef;
window.addEventListener("message", this.handleIncomingMessage);
// Clean old timestamps periodically
this.cleanupInterval = setInterval(
() => this.cleanOldTimestamps(),
SECURITY.MESSAGE_TIMESTAMP_CLEANUP_INTERVAL_MS
);
}
private cleanOldTimestamps(): void {
const cutoffTime = Date.now() - SECURITY.MESSAGE_TIMESTAMP_RETENTION_MS;
for (const [id, timestamp] of this.messageTimestamps.entries()) {
if (timestamp < cutoffTime) {
this.messageTimestamps.delete(id);
}
}
}
setAllowedOrigin(origin: string): void {
if (origin && !this.allowedOrigins.includes(origin)) {
this.allowedOrigins.push(origin);
}
}
on = (method: SDKMethods, handler: MessageHandler): void => {
@@ -38,14 +63,53 @@ class AppCommunicator {
};
private isValidMessage = (msg: SDKMessageEvent): boolean => {
// Validate message structure
if (!msg.data || typeof msg.data !== 'object') {
return false;
}
// Check iframe source
const sentFromIframe = this.iframeRef.current?.contentWindow === msg.source;
if (!sentFromIframe) {
return false;
}
// Check for known method
const knownMethod = Object.values(Methods).includes(msg.data.method);
if (!knownMethod && !Object.values(LegacyMethods).includes(msg.data.method as unknown as LegacyMethods)) {
return false;
}
// Replay protection - check timestamp
const messageId = `${msg.data.id}_${msg.data.method}`;
const now = Date.now();
const lastTimestamp = this.messageTimestamps.get(messageId) || 0;
// Reject messages within replay window (potential replay)
if (now - lastTimestamp < SECURITY.MESSAGE_REPLAY_WINDOW_MS) {
return false;
}
this.messageTimestamps.set(messageId, now);
// Validate origin if allowed origins are set
if (this.allowedOrigins.length > 0 && msg.origin) {
try {
const messageOrigin = new URL(msg.origin).origin;
if (!this.allowedOrigins.includes(messageOrigin)) {
return false;
}
} catch {
return false;
}
}
// Special case for cookie check (legacy support)
if (msg.data.hasOwnProperty("isCookieEnabled")) {
return true;
}
const sentFromIframe = this.iframeRef.current?.contentWindow === msg.source;
const knownMethod = Object.values(Methods).includes(msg.data.method);
return sentFromIframe && knownMethod;
return true;
};
private canHandleMessage = (msg: SDKMessageEvent): boolean => {
@@ -61,8 +125,18 @@ class AppCommunicator {
sdkVersion
)
: MessageFormatter.makeResponse(requestId, data, sdkVersion);
// console.log("send", { msg });
this.iframeRef.current?.contentWindow?.postMessage(msg, "*");
// Get target origin - use specific origin instead of wildcard
const getTargetOrigin = (): string => {
if (this.allowedOrigins.length > 0) {
return this.allowedOrigins[0];
}
// Fallback to current origin if no specific origin set
return typeof window !== "undefined" ? window.location.origin : "*";
};
const targetOrigin = getTargetOrigin();
this.iframeRef.current?.contentWindow?.postMessage(msg, targetOrigin);
};
handleIncomingMessage = async (msg: SDKMessageEvent): Promise<void> => {
@@ -89,6 +163,10 @@ class AppCommunicator {
clear = (): void => {
window.removeEventListener("message", this.handleIncomingMessage);
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
};
}

104
helpers/relayers/index.ts Normal file
View File

@@ -0,0 +1,104 @@
import { TransactionRequest } from "../../types";
export interface RelayerService {
id: string;
name: string;
apiUrl: string;
apiKey?: string;
enabled: boolean;
}
export const DEFAULT_RELAYERS: RelayerService[] = [
{
id: "openrelay",
name: "OpenRelay",
apiUrl: "https://api.openrelay.xyz/v1/relay",
enabled: true,
},
{
id: "gelato",
name: "Gelato",
apiUrl: "https://relay.gelato.digital",
enabled: true,
},
{
id: "custom",
name: "Custom Relayer",
apiUrl: "",
enabled: false,
},
];
export async function submitToRelayer(
tx: TransactionRequest,
relayer: RelayerService
): Promise<string> {
if (!relayer.enabled || !relayer.apiUrl) {
throw new Error(`Relayer ${relayer.name} is not configured`);
}
const payload = {
to: tx.to,
value: tx.value || "0",
data: tx.data || "0x",
gasLimit: tx.gasLimit,
gasPrice: tx.gasPrice,
maxFeePerGas: tx.maxFeePerGas,
maxPriorityFeePerGas: tx.maxPriorityFeePerGas,
};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (relayer.apiKey) {
headers["Authorization"] = `Bearer ${relayer.apiKey}`;
}
const response = await fetch(relayer.apiUrl, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Relayer request failed: ${error}`);
}
const result = await response.json();
return result.txHash || result.hash || result.transactionHash;
}
export async function getRelayerStatus(
txHash: string,
relayer: RelayerService
): Promise<{ status: string; confirmed: boolean }> {
if (!relayer.enabled || !relayer.apiUrl) {
throw new Error(`Relayer ${relayer.name} is not configured`);
}
const statusUrl = `${relayer.apiUrl}/status/${txHash}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (relayer.apiKey) {
headers["Authorization"] = `Bearer ${relayer.apiKey}`;
}
const response = await fetch(statusUrl, {
method: "GET",
headers,
});
if (!response.ok) {
return { status: "unknown", confirmed: false };
}
const result = await response.json();
return {
status: result.status || "pending",
confirmed: result.confirmed || false,
};
}

View File

@@ -0,0 +1,112 @@
import { ethers, providers } from "ethers";
import { SmartWalletConfig, SmartWalletType } from "../../types";
// ERC-4337 Account Abstraction support
// This is a placeholder implementation - full implementation would require
// bundler service integration and UserOperation creation
export interface ERC4337Config {
entryPoint: string;
factory: string;
bundlerUrl: string;
}
const ERC4337_CONFIGS: Record<number, ERC4337Config> = {
1: {
entryPoint: "0x0576a174D229E3cFA37253523E645A78A0C91B57",
factory: "0x9406Cc6185a346906296840746125a0E44976454",
bundlerUrl: "https://bundler.eth-infinitism.com/rpc",
},
5: {
entryPoint: "0x0576a174D229E3cFA37253523E645A78A0C91B57",
factory: "0x9406Cc6185a346906296840746125a0E44976454",
bundlerUrl: "https://bundler-goerli.eth-infinitism.com/rpc",
},
};
export async function connectToERC4337(
accountAddress: string,
networkId: number,
provider: providers.Provider
): Promise<SmartWalletConfig | null> {
try {
// In full implementation, this would:
// 1. Verify the account is an ERC-4337 account
// 2. Fetch owners/signers from the account
// 3. Get threshold configuration
// For now, return a placeholder config
return {
id: `erc4337_${accountAddress}_${networkId}`,
type: SmartWalletType.ERC4337,
address: accountAddress,
networkId,
owners: [accountAddress], // Placeholder
threshold: 1, // Placeholder
createdAt: Date.now(),
updatedAt: Date.now(),
};
} catch (error) {
console.error("Failed to connect to ERC-4337 account", error);
return null;
}
}
export async function createUserOperation(
to: string,
value: string,
data: string,
accountAddress: string,
networkId: number
): Promise<any> {
const config = ERC4337_CONFIGS[networkId];
if (!config) {
throw new Error(`ERC-4337 not supported on network ${networkId}`);
}
// Placeholder UserOperation structure
// Full implementation would:
// 1. Get nonce from account
// 2. Calculate callData
// 3. Estimate gas
// 4. Sign with account owner
return {
sender: accountAddress,
nonce: "0x0",
initCode: "0x",
callData: data || "0x",
callGasLimit: "0x0",
verificationGasLimit: "0x0",
preVerificationGas: "0x0",
maxFeePerGas: "0x0",
maxPriorityFeePerGas: "0x0",
paymasterAndData: "0x",
signature: "0x",
};
}
export async function sendUserOperation(
userOp: any,
bundlerUrl: string
): Promise<string> {
// Placeholder - full implementation would send to bundler
const response = await fetch(bundlerUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_sendUserOperation",
params: [userOp, "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"], // EntryPoint
}),
});
const result = await response.json();
if (result.error) {
throw new Error(result.error.message);
}
return result.result;
}

View File

@@ -0,0 +1,193 @@
import { ethers, providers } from "ethers";
import Safe, { SafeFactory, SafeAccountConfig } from "@safe-global/safe-core-sdk";
import EthersAdapter from "@safe-global/safe-ethers-lib";
import { SafeInfo, SmartWalletConfig, OwnerInfo, SmartWalletType } from "../../types";
// Gnosis Safe Factory contract addresses per network
// Note: These are the Safe Factory addresses, not the Safe contract itself
// The Safe SDK handles the correct addresses internally
const SAFE_FACTORY_ADDRESSES: Record<number, string> = {
1: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Mainnet - Safe Factory v1.3.0
5: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Goerli - Safe Factory v1.3.0
100: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Gnosis Chain
137: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Polygon
42161: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Arbitrum
10: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Optimism
8453: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // Base
};
// Note: The Safe SDK uses its own internal address resolution
// These addresses are for reference only
export async function getSafeInfo(
safeAddress: string,
provider: providers.Provider
): Promise<SafeInfo | null> {
try {
// Validate address
if (!ethers.utils.isAddress(safeAddress)) {
throw new Error("Invalid Safe address");
}
const network = await provider.getNetwork();
// Verify this is actually a Safe contract by checking for Safe-specific functions
const safeContract = new ethers.Contract(
safeAddress,
[
"function getOwners() view returns (address[])",
"function getThreshold() view returns (uint256)",
"function nonce() view returns (uint256)",
"function VERSION() view returns (string)",
],
provider
);
// Try to get VERSION to verify it's a Safe
let isSafe = false;
try {
await safeContract.VERSION();
isSafe = true;
} catch {
// Not a Safe contract
isSafe = false;
}
if (!isSafe) {
throw new Error("Address is not a valid Safe contract");
}
const [owners, threshold] = await Promise.all([
safeContract.getOwners(),
safeContract.getThreshold(),
]);
// Validate owners array
if (!Array.isArray(owners) || owners.length === 0) {
throw new Error("Invalid Safe configuration: no owners");
}
// Validate threshold
const thresholdNum = threshold.toNumber();
if (thresholdNum < 1 || thresholdNum > owners.length) {
throw new Error("Invalid Safe configuration: invalid threshold");
}
const balance = await provider.getBalance(safeAddress);
return {
safeAddress: ethers.utils.getAddress(safeAddress), // Ensure checksummed
network: network.name as any,
ethBalance: balance.toString(),
owners: owners.map((o: string) => ethers.utils.getAddress(o)), // Checksum all owners
threshold: thresholdNum,
};
} catch (error: any) {
console.error("Failed to get Safe info", error);
return null;
}
}
export async function connectToSafe(
safeAddress: string,
networkId: number,
provider: providers.Provider
): Promise<SmartWalletConfig | null> {
// Validate address
if (!ethers.utils.isAddress(safeAddress)) {
throw new Error("Invalid Safe address");
}
const checksummedAddress = ethers.utils.getAddress(safeAddress);
const safeInfo = await getSafeInfo(checksummedAddress, provider);
if (!safeInfo) {
return null;
}
return {
id: `safe_${checksummedAddress}_${networkId}`,
type: SmartWalletType.GNOSIS_SAFE,
address: checksummedAddress,
networkId,
owners: (safeInfo as any).owners || [],
threshold: (safeInfo as any).threshold || 1,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export async function deploySafe(
owners: string[],
threshold: number,
provider: providers.Provider,
signer: ethers.Signer
): Promise<string | null> {
try {
// Validate inputs
if (!owners || owners.length === 0) {
throw new Error("At least one owner is required");
}
if (threshold < 1 || threshold > owners.length) {
throw new Error("Threshold must be between 1 and owner count");
}
// Validate and checksum all owner addresses
const validatedOwners = owners.map((owner) => {
if (!ethers.utils.isAddress(owner)) {
throw new Error(`Invalid owner address: ${owner}`);
}
return ethers.utils.getAddress(owner);
});
// Check for duplicate owners
const uniqueOwners = new Set(validatedOwners.map(o => o.toLowerCase()));
if (uniqueOwners.size !== validatedOwners.length) {
throw new Error("Duplicate owner addresses are not allowed");
}
const ethAdapter = new EthersAdapter({
ethers,
signerOrProvider: signer,
});
const safeFactory = await (SafeFactory as any).init({ ethAdapter });
const safeAccountConfig: SafeAccountConfig = {
owners: validatedOwners,
threshold,
};
const safeSdk = await safeFactory.deploySafe({ safeAccountConfig });
const safeAddress = safeSdk.getAddress();
return safeAddress;
} catch (error: any) {
console.error("Failed to deploy Safe", error);
throw error;
}
}
export async function getSafeSDK(
safeAddress: string,
provider: providers.Provider,
signer?: ethers.Signer
): Promise<Safe | null> {
try {
// Validate address
if (!ethers.utils.isAddress(safeAddress)) {
throw new Error("Invalid Safe address");
}
const checksummedAddress = ethers.utils.getAddress(safeAddress);
const ethAdapter = new EthersAdapter({
ethers,
signerOrProvider: signer || provider,
});
const safeSdk = await (Safe as any).init({ ethAdapter, safeAddress: checksummedAddress });
return safeSdk;
} catch (error: any) {
console.error("Failed to initialize Safe SDK", error);
return null;
}
}

View File

@@ -0,0 +1,250 @@
import { providers, ethers } from "ethers";
import { TransactionRequest, TransactionExecutionMethod } from "../../types";
import { validateAddress, validateTransactionValue, validateGasLimit } from "../../utils/security";
import { SECURITY } from "../../utils/constants";
export async function executeDirectTransaction(
tx: TransactionRequest,
provider: providers.Provider,
signer: ethers.Signer
): Promise<string> {
// Validate addresses
if (!tx.to) {
throw new Error("Missing 'to' address");
}
const toValidation = validateAddress(tx.to);
if (!toValidation.valid) {
throw new Error(`Invalid 'to' address: ${toValidation.error}`);
}
// Validate value
if (tx.value) {
const valueValidation = validateTransactionValue(tx.value);
if (!valueValidation.valid) {
throw new Error(`Invalid transaction value: ${valueValidation.error}`);
}
}
// Validate gas limit if provided
if (tx.gasLimit) {
const gasValidation = validateGasLimit(tx.gasLimit);
if (!gasValidation.valid) {
throw new Error(`Invalid gas limit: ${gasValidation.error}`);
}
}
// Validate gas estimate if provided
if (tx.gasLimit) {
const MAX_GAS_LIMIT = ethers.BigNumber.from(SECURITY.MAX_GAS_LIMIT);
const gasLimitBN = ethers.BigNumber.from(tx.gasLimit);
if (gasLimitBN.gt(MAX_GAS_LIMIT)) {
throw new Error(`Gas limit ${gasLimitBN.toString()} exceeds maximum ${MAX_GAS_LIMIT.toString()}`);
}
}
const txParams: any = {
to: toValidation.checksummed!,
value: tx.value ? ethers.BigNumber.from(tx.value) : 0,
data: tx.data || "0x",
};
if (tx.gasLimit) {
txParams.gasLimit = ethers.BigNumber.from(tx.gasLimit);
}
if (tx.maxFeePerGas && tx.maxPriorityFeePerGas) {
txParams.maxFeePerGas = ethers.BigNumber.from(tx.maxFeePerGas);
txParams.maxPriorityFeePerGas = ethers.BigNumber.from(tx.maxPriorityFeePerGas);
} else if (tx.gasPrice) {
txParams.gasPrice = ethers.BigNumber.from(tx.gasPrice);
}
if (tx.nonce !== undefined) {
txParams.nonce = tx.nonce;
}
const transaction = await signer.sendTransaction(txParams);
return transaction.hash;
}
export async function executeRelayerTransaction(
tx: TransactionRequest,
relayerUrl: string,
apiKey?: string
): Promise<string> {
// Validate relayer URL
try {
const url = new URL(relayerUrl);
if (url.protocol !== "https:") {
throw new Error("Relayer URL must use HTTPS");
}
} catch {
throw new Error("Invalid relayer URL");
}
// Validate addresses
if (!tx.to) {
throw new Error("Missing 'to' address");
}
const toValidation = validateAddress(tx.to);
if (!toValidation.valid) {
throw new Error(`Invalid 'to' address: ${toValidation.error}`);
}
// Validate value
if (tx.value) {
const valueValidation = validateTransactionValue(tx.value);
if (!valueValidation.valid) {
throw new Error(`Invalid transaction value: ${valueValidation.error}`);
}
}
const payload: any = {
to: toValidation.checksummed!,
value: tx.value || "0",
data: tx.data || "0x",
};
if (tx.gasLimit) {
const gasValidation = validateGasLimit(tx.gasLimit);
if (!gasValidation.valid) {
throw new Error(`Invalid gas limit: ${gasValidation.error}`);
}
payload.gasLimit = tx.gasLimit;
}
if (tx.maxFeePerGas && tx.maxPriorityFeePerGas) {
payload.maxFeePerGas = tx.maxFeePerGas;
payload.maxPriorityFeePerGas = tx.maxPriorityFeePerGas;
} else if (tx.gasPrice) {
payload.gasPrice = tx.gasPrice;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
// Add timeout to prevent hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SECURITY.RELAYER_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(relayerUrl, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Relayer request failed: ${errorText || response.statusText}`);
}
const result = await response.json();
const txHash = result.txHash || result.hash || result.transactionHash;
if (!txHash) {
throw new Error("Relayer did not return transaction hash");
}
return txHash;
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error("Relayer request timeout");
}
throw error;
}
}
export async function simulateTransaction(
tx: TransactionRequest,
provider: providers.Provider,
from: string
): Promise<{ success: boolean; gasUsed: string; error?: string }> {
try {
// Validate addresses
const fromValidation = validateAddress(from);
if (!fromValidation.valid) {
return {
success: false,
gasUsed: "0",
error: `Invalid 'from' address: ${fromValidation.error}`,
};
}
if (!tx.to) {
return {
success: false,
gasUsed: "0",
error: "Missing 'to' address",
};
}
const toValidation = validateAddress(tx.to);
if (!toValidation.valid) {
return {
success: false,
gasUsed: "0",
error: `Invalid 'to' address: ${toValidation.error}`,
};
}
// Validate value
if (tx.value) {
const valueValidation = validateTransactionValue(tx.value);
if (!valueValidation.valid) {
return {
success: false,
gasUsed: "0",
error: `Invalid transaction value: ${valueValidation.error}`,
};
}
}
// Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Gas estimation timeout")), SECURITY.GAS_ESTIMATION_TIMEOUT_MS)
);
const gasEstimate = await Promise.race([
provider.estimateGas({
from: fromValidation.checksummed!,
to: toValidation.checksummed!,
value: tx.value ? ethers.BigNumber.from(tx.value) : undefined,
data: tx.data || "0x",
}),
timeoutPromise,
]) as ethers.BigNumber;
// Validate gas estimate
const MAX_GAS_LIMIT = ethers.BigNumber.from(SECURITY.MAX_GAS_LIMIT);
if (gasEstimate.gt(MAX_GAS_LIMIT)) {
return {
success: false,
gasUsed: "0",
error: `Gas estimate ${gasEstimate.toString()} exceeds maximum ${MAX_GAS_LIMIT.toString()}`,
};
}
return {
success: true,
gasUsed: gasEstimate.toString(),
};
} catch (error: any) {
return {
success: false,
gasUsed: "0",
error: error.message || "Simulation failed",
};
}
}