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

View File

@@ -6,8 +6,11 @@ import React, {
useRef,
useCallback,
} from "react";
import { providers, utils } from "ethers";
import { providers, utils, ethers } from "ethers";
import { useAppCommunicator } from "../helpers/communicator";
import { useSmartWallet } from "./SmartWalletContext";
import { useTransaction } from "./TransactionContext";
import { getWalletBalance } from "../helpers/balance";
import {
InterfaceMessageIds,
InterfaceMessageProps,
@@ -64,6 +67,20 @@ export const SafeInjectProvider: React.FunctionComponent<FCProps> = ({
const iframeRef = useRef<HTMLIFrameElement>(null);
const communicator = useAppCommunicator(iframeRef);
const { activeWallet, setProvider: setSmartWalletProvider } = useSmartWallet();
const { createTransaction } = useTransaction();
// Set allowed origin for iframe communication
useEffect(() => {
if (appUrl && communicator) {
try {
const url = new URL(appUrl);
communicator.setAllowedOrigin(url.origin);
} catch (e) {
console.error("Invalid app URL:", e);
}
}
}, [appUrl, communicator]);
const sendMessageToIFrame = useCallback(
function <T extends InterfaceMessageIds>(
@@ -89,19 +106,35 @@ export const SafeInjectProvider: React.FunctionComponent<FCProps> = ({
useEffect(() => {
if (!rpcUrl) return;
setProvider(new providers.StaticJsonRpcProvider(rpcUrl));
}, [rpcUrl]);
const newProvider = new providers.StaticJsonRpcProvider(rpcUrl);
setProvider(newProvider);
setSmartWalletProvider(newProvider);
}, [rpcUrl, setSmartWalletProvider]);
useEffect(() => {
if (!provider) return;
communicator?.on(Methods.getSafeInfo, async () => ({
safeAddress: address,
chainId: (await provider.getNetwork()).chainId,
owners: [],
threshold: 1,
isReadOnly: false,
}));
communicator?.on(Methods.getSafeInfo, async () => {
// Use active smart wallet if available, otherwise fall back to impersonated address
if (activeWallet && provider) {
const network = await provider.getNetwork();
const balance = await provider.getBalance(activeWallet.address);
return {
safeAddress: activeWallet.address,
network: network.name as any,
ethBalance: balance.toString(),
};
}
// Fallback to impersonated address
const network = await provider.getNetwork();
const balance = address ? await provider.getBalance(address) : ethers.BigNumber.from(0);
return {
safeAddress: address || "0x0000000000000000000000000000000000000000",
network: network.name as any,
ethBalance: balance.toString(),
};
});
communicator?.on(Methods.getEnvironmentInfo, async () => ({
origin: document.location.origin,
@@ -121,7 +154,7 @@ export const SafeInjectProvider: React.FunctionComponent<FCProps> = ({
}
});
communicator?.on(Methods.sendTransactions, (msg) => {
communicator?.on(Methods.sendTransactions, async (msg) => {
// @ts-expect-error explore ways to fix this
const transactions = (msg.data.params.txs as Transaction[]).map(
({ to, ...rest }) => ({
@@ -129,11 +162,41 @@ export const SafeInjectProvider: React.FunctionComponent<FCProps> = ({
...rest,
})
);
const tx = transactions[0];
setLatestTransaction({
id: parseInt(msg.data.id.toString()),
...transactions[0],
...tx,
});
// openConfirmationModal(transactions, msg.data.params.params, msg.data.id)
// Create transaction in transaction context for approval/execution
if (activeWallet) {
try {
// Validate transaction data
const { validateTransactionRequest } = await import("../utils/security");
const validation = validateTransactionRequest({
from: activeWallet.address,
to: tx.to,
value: tx.value || "0",
data: tx.data || "0x",
});
if (!validation.valid) {
console.error("Invalid transaction from iframe:", validation.errors);
return;
}
await createTransaction({
from: activeWallet.address,
to: tx.to,
value: tx.value || "0",
data: tx.data || "0x",
method: "DIRECT_ONCHAIN" as any,
});
} catch (error: any) {
console.error("Failed to create transaction from iframe:", error);
}
}
});
communicator?.on(Methods.signMessage, async (msg) => {
@@ -147,7 +210,59 @@ export const SafeInjectProvider: React.FunctionComponent<FCProps> = ({
// openSignMessageModal(typedData, msg.data.id, Methods.signTypedMessage)
});
}, [communicator, address, provider]);
communicator?.on(Methods.getSafeBalances, async () => {
if (!activeWallet || !provider) {
return [];
}
try {
const network = await provider.getNetwork();
const balance = await getWalletBalance(
activeWallet.address,
network.chainId,
provider
);
return [
{
fiatTotal: "0",
items: [
{
tokenInfo: {
type: "NATIVE_TOKEN" as any,
address: "0x0000000000000000000000000000000000000000",
decimals: 18,
symbol: "ETH",
name: "Ether",
logoUri: "",
},
balance: balance.native,
fiatBalance: "0",
fiatConversion: "0",
},
...balance.tokens.map((token) => ({
tokenInfo: {
type: "ERC20" as any,
address: token.tokenAddress,
decimals: token.decimals,
symbol: token.symbol,
name: token.name,
logoUri: token.logoUri || "",
},
balance: token.balance,
fiatBalance: "0",
fiatConversion: "0",
})),
],
},
];
} catch (error) {
console.error("Failed to get Safe balances", error);
return [];
}
});
}, [communicator, address, provider, activeWallet, createTransaction]);
return (
<SafeInjectContext.Provider

View File

@@ -0,0 +1,412 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { providers } from "ethers";
import {
SmartWalletConfig,
SmartWalletType,
OwnerInfo,
WalletBalance,
TokenBalance,
} from "../types";
import { getWalletBalance } from "../helpers/balance";
import { SecureStorage } from "../utils/encryption";
import { validateAddress, isContractAddress, validateNetworkId } from "../utils/security";
import { STORAGE_KEYS } from "../utils/constants";
interface SmartWalletContextType {
// Smart wallet state
smartWallets: SmartWalletConfig[];
activeWallet: SmartWalletConfig | undefined;
setActiveWallet: (wallet: SmartWalletConfig | undefined) => void;
// Wallet operations
createWallet: (config: Omit<SmartWalletConfig, "id" | "createdAt" | "updatedAt">) => Promise<SmartWalletConfig>;
updateWallet: (id: string, updates: Partial<SmartWalletConfig>) => void;
deleteWallet: (id: string) => void;
connectToWallet: (address: string, networkId: number, type: SmartWalletType) => Promise<SmartWalletConfig | null>;
// Owner management
addOwner: (walletId: string, owner: OwnerInfo, callerAddress?: string) => Promise<void>;
removeOwner: (walletId: string, ownerAddress: string, callerAddress?: string) => Promise<void>;
updateThreshold: (walletId: string, threshold: number, callerAddress?: string) => Promise<void>;
// Balance management
balance: WalletBalance | undefined;
refreshBalance: () => Promise<void>;
isLoadingBalance: boolean;
// Provider
provider: providers.Provider | undefined;
setProvider: (provider: providers.Provider | undefined) => void;
}
export const SmartWalletContext = createContext<SmartWalletContextType>({
smartWallets: [],
activeWallet: undefined,
setActiveWallet: () => {},
createWallet: async () => ({} as SmartWalletConfig),
updateWallet: () => {},
deleteWallet: () => {},
connectToWallet: async () => null,
addOwner: async () => {},
removeOwner: async () => {},
updateThreshold: async () => {},
balance: undefined,
refreshBalance: async () => {},
isLoadingBalance: false,
provider: undefined,
setProvider: () => {},
});
export interface FCProps {
children: React.ReactNode;
}
const secureStorage = new SecureStorage();
export const SmartWalletProvider: React.FunctionComponent<FCProps> = ({
children,
}) => {
const [smartWallets, setSmartWallets] = useState<SmartWalletConfig[]>([]);
const [activeWallet, setActiveWallet] = useState<SmartWalletConfig | undefined>();
const [balance, setBalance] = useState<WalletBalance | undefined>();
const [isLoadingBalance, setIsLoadingBalance] = useState(false);
const [provider, setProvider] = useState<providers.Provider>();
// Load wallets from secure storage on mount
useEffect(() => {
const loadWallets = async () => {
if (typeof window !== "undefined") {
try {
const stored = await secureStorage.getItem(STORAGE_KEYS.SMART_WALLETS);
if (stored) {
const wallets = JSON.parse(stored) as SmartWalletConfig[];
setSmartWallets(wallets);
// Restore active wallet if exists
const activeId = await secureStorage.getItem(STORAGE_KEYS.ACTIVE_WALLET);
if (activeId) {
const wallet = wallets.find((w) => w.id === activeId);
if (wallet) {
setActiveWallet(wallet);
}
}
}
} catch (e) {
console.error("Failed to load wallets from storage", e);
}
}
};
loadWallets();
}, []);
// Save wallets to secure storage whenever they change
useEffect(() => {
const saveWallets = async () => {
if (typeof window !== "undefined") {
try {
await secureStorage.setItem(STORAGE_KEYS.SMART_WALLETS, JSON.stringify(smartWallets));
if (activeWallet) {
await secureStorage.setItem(STORAGE_KEYS.ACTIVE_WALLET, activeWallet.id);
} else {
secureStorage.removeItem(STORAGE_KEYS.ACTIVE_WALLET);
}
} catch (e) {
console.error("Failed to save wallets to storage", e);
}
}
};
saveWallets();
}, [smartWallets, activeWallet]);
const createWallet = useCallback(
async (config: Omit<SmartWalletConfig, "id" | "createdAt" | "updatedAt">): Promise<SmartWalletConfig> => {
const newWallet: SmartWalletConfig = {
...config,
id: `wallet_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`,
createdAt: Date.now(),
updatedAt: Date.now(),
};
setSmartWallets((prev) => [...prev, newWallet]);
return newWallet;
},
[]
);
const updateWallet = useCallback((id: string, updates: Partial<SmartWalletConfig>) => {
setSmartWallets((prev) =>
prev.map((wallet) =>
wallet.id === id
? { ...wallet, ...updates, updatedAt: Date.now() }
: wallet
)
);
if (activeWallet?.id === id) {
setActiveWallet((prev) => (prev ? { ...prev, ...updates, updatedAt: Date.now() } : undefined));
}
}, [activeWallet]);
const deleteWallet = useCallback((id: string) => {
setSmartWallets((prev) => prev.filter((wallet) => wallet.id !== id));
if (activeWallet?.id === id) {
setActiveWallet(undefined);
}
}, [activeWallet]);
const connectToWallet = useCallback(
async (
address: string,
networkId: number,
type: SmartWalletType
): Promise<SmartWalletConfig | null> => {
// Validate network ID
const networkValidation = validateNetworkId(networkId);
if (!networkValidation.valid) {
throw new Error(networkValidation.error || "Invalid network ID");
}
// Validate address
const addressValidation = validateAddress(address);
if (!addressValidation.valid) {
throw new Error(addressValidation.error || "Invalid address");
}
const validatedAddress = addressValidation.checksummed!;
// Check if wallet already exists
const existing = smartWallets.find(
(w) => w.address.toLowerCase() === validatedAddress.toLowerCase() && w.networkId === networkId
);
if (existing) {
setActiveWallet(existing);
return existing;
}
// Connect based on wallet type
if (type === SmartWalletType.GNOSIS_SAFE && provider) {
const { connectToSafe } = await import("../helpers/smartWallet/gnosisSafe");
const wallet = await connectToSafe(validatedAddress, networkId, provider);
if (wallet) {
setActiveWallet(wallet);
setSmartWallets((prev) => {
const exists = prev.find((w) => w.id === wallet.id);
if (exists) return prev;
return [...prev, wallet];
});
return wallet;
}
} else if (type === SmartWalletType.ERC4337 && provider) {
const { connectToERC4337 } = await import("../helpers/smartWallet/erc4337");
const wallet = await connectToERC4337(validatedAddress, networkId, provider);
if (wallet) {
setActiveWallet(wallet);
setSmartWallets((prev) => {
const exists = prev.find((w) => w.id === wallet.id);
if (exists) return prev;
return [...prev, wallet];
});
return wallet;
}
}
// Fallback: create a placeholder wallet config
const newWallet = await createWallet({
type,
address: validatedAddress,
networkId,
owners: [validatedAddress],
threshold: 1,
});
setActiveWallet(newWallet);
return newWallet;
},
[smartWallets, createWallet, provider]
);
const addOwner = useCallback(async (
walletId: string,
owner: OwnerInfo,
callerAddress?: string
) => {
const wallet = smartWallets.find((w) => w.id === walletId);
if (!wallet) {
throw new Error("Wallet not found");
}
// Validate address
const addressValidation = validateAddress(owner.address);
if (!addressValidation.valid) {
throw new Error(addressValidation.error || "Invalid owner address");
}
const checksummedAddress = addressValidation.checksummed!;
// Check if contract (cannot add contracts as owners)
if (provider) {
const isContract = await isContractAddress(checksummedAddress, provider);
if (isContract) {
throw new Error("Cannot add contract address as owner");
}
}
// Check for duplicates
if (wallet.owners.some(
o => o.toLowerCase() === checksummedAddress.toLowerCase()
)) {
throw new Error("Owner already exists");
}
// Verify caller is owner (if caller address provided)
if (callerAddress && wallet.type === SmartWalletType.GNOSIS_SAFE && provider) {
const { getSafeInfo } = await import("../helpers/smartWallet/gnosisSafe");
const safeInfo = await getSafeInfo(wallet.address, provider);
if (safeInfo && (safeInfo as any).owners && !(safeInfo as any).owners.some(
(o: string) => o.toLowerCase() === callerAddress.toLowerCase()
)) {
throw new Error("Unauthorized: Caller is not a wallet owner");
}
}
updateWallet(walletId, {
owners: [...wallet.owners, checksummedAddress],
});
}, [smartWallets, provider, updateWallet]);
const removeOwner = useCallback(
async (walletId: string, ownerAddress: string, callerAddress?: string) => {
const wallet = smartWallets.find((w) => w.id === walletId);
if (!wallet) {
throw new Error("Wallet not found");
}
// Validate address
const addressValidation = validateAddress(ownerAddress);
if (!addressValidation.valid) {
throw new Error(addressValidation.error || "Invalid owner address");
}
const checksummedAddress = addressValidation.checksummed!;
// Cannot remove last owner
if (wallet.owners.length <= 1) {
throw new Error("Cannot remove last owner");
}
const newOwners = wallet.owners.filter(
(o) => o.toLowerCase() !== checksummedAddress.toLowerCase()
);
if (newOwners.length < wallet.threshold) {
throw new Error("Cannot remove owner: threshold would exceed owner count");
}
// Verify caller is owner (if caller address provided)
if (callerAddress && wallet.type === SmartWalletType.GNOSIS_SAFE && provider) {
const { getSafeInfo } = await import("../helpers/smartWallet/gnosisSafe");
const safeInfo = await getSafeInfo(wallet.address, provider);
if (safeInfo && (safeInfo as any).owners && !(safeInfo as any).owners.some(
(o: string) => o.toLowerCase() === callerAddress.toLowerCase()
)) {
throw new Error("Unauthorized: Caller is not a wallet owner");
}
}
updateWallet(walletId, { owners: newOwners });
},
[smartWallets, provider, updateWallet]
);
const updateThreshold = useCallback(
async (walletId: string, threshold: number, callerAddress?: string) => {
const wallet = smartWallets.find((w) => w.id === walletId);
if (!wallet) {
throw new Error("Wallet not found");
}
if (threshold < 1) {
throw new Error("Threshold must be at least 1");
}
if (threshold > wallet.owners.length) {
throw new Error("Threshold cannot exceed owner count");
}
// Verify caller is owner (if caller address provided)
if (callerAddress && wallet.type === SmartWalletType.GNOSIS_SAFE && provider) {
const { getSafeInfo } = await import("../helpers/smartWallet/gnosisSafe");
const safeInfo = await getSafeInfo(wallet.address, provider);
if (safeInfo && (safeInfo as any).owners && !(safeInfo as any).owners.some(
(o: string) => o.toLowerCase() === callerAddress.toLowerCase()
)) {
throw new Error("Unauthorized: Caller is not a wallet owner");
}
}
updateWallet(walletId, { threshold });
},
[smartWallets, provider, updateWallet]
);
const refreshBalance = useCallback(async () => {
if (!activeWallet || !provider) {
setBalance(undefined);
return;
}
setIsLoadingBalance(true);
try {
const network = await provider.getNetwork();
const balance = await getWalletBalance(
activeWallet.address,
network.chainId,
provider
);
setBalance(balance);
} catch (error) {
console.error("Failed to fetch balance", error);
setBalance(undefined);
} finally {
setIsLoadingBalance(false);
}
}, [activeWallet, provider]);
// Refresh balance when active wallet or provider changes
useEffect(() => {
refreshBalance();
}, [refreshBalance]);
return (
<SmartWalletContext.Provider
value={{
smartWallets,
activeWallet,
setActiveWallet,
createWallet,
updateWallet,
deleteWallet,
connectToWallet,
addOwner,
removeOwner,
updateThreshold,
balance,
refreshBalance,
isLoadingBalance,
provider,
setProvider,
}}
>
{children}
</SmartWalletContext.Provider>
);
};
export const useSmartWallet = () => useContext(SmartWalletContext);

View File

@@ -0,0 +1,530 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { providers, ethers } from "ethers";
import {
TransactionRequest,
TransactionRequestStatus,
TransactionStatus,
TransactionExecutionMethod,
GasEstimate,
PendingTransaction,
MultiSigApproval,
} from "../types";
import { useSmartWallet } from "./SmartWalletContext";
import { executeDirectTransaction, executeRelayerTransaction, simulateTransaction } from "../helpers/transaction/execution";
import { submitToRelayer, DEFAULT_RELAYERS } from "../helpers/relayers";
import { generateSecureId, validateTransactionRequest, RateLimiter, NonceManager, validateGasLimit } from "../utils/security";
import { SecureStorage } from "../utils/encryption";
import { SECURITY, STORAGE_KEYS, DEFAULTS } from "../utils/constants";
interface TransactionContextType {
// Transaction state
transactions: TransactionRequest[];
pendingTransactions: PendingTransaction[];
// Transaction operations
createTransaction: (tx: Omit<TransactionRequest, "id" | "status" | "createdAt">) => Promise<TransactionRequest>;
updateTransaction: (id: string, updates: Partial<TransactionRequest>) => void;
approveTransaction: (transactionId: string, approver: string) => Promise<void>;
rejectTransaction: (transactionId: string, approver: string) => Promise<void>;
executeTransaction: (transactionId: string) => Promise<string | null>;
// Gas estimation
estimateGas: (tx: Partial<TransactionRequest>) => Promise<GasEstimate | null>;
// Execution method
defaultExecutionMethod: TransactionExecutionMethod;
setDefaultExecutionMethod: (method: TransactionExecutionMethod) => void;
}
export const TransactionContext = createContext<TransactionContextType>({
transactions: [],
pendingTransactions: [],
createTransaction: async () => ({} as TransactionRequest),
updateTransaction: () => {},
approveTransaction: async () => {},
rejectTransaction: async () => {},
executeTransaction: async () => null,
estimateGas: async () => null,
defaultExecutionMethod: TransactionExecutionMethod.DIRECT_ONCHAIN,
setDefaultExecutionMethod: () => {},
});
export interface FCProps {
children: React.ReactNode;
}
const secureStorage = new SecureStorage();
export const TransactionProvider: React.FunctionComponent<FCProps> = ({
children,
}) => {
const { activeWallet, provider } = useSmartWallet();
const [transactions, setTransactions] = useState<TransactionRequest[]>([]);
const [approvals, setApprovals] = useState<Record<string, MultiSigApproval[]>>({});
const [defaultExecutionMethod, setDefaultExecutionMethod] = useState<TransactionExecutionMethod>(
TransactionExecutionMethod.SIMULATION as TransactionExecutionMethod // Safer default
);
const approvalLocks = new Map<string, boolean>();
const rateLimiter = new RateLimiter();
const nonceManager = provider ? new NonceManager(provider) : null;
// Load transactions from secure storage
useEffect(() => {
const loadTransactions = async () => {
if (typeof window !== "undefined") {
try {
const stored = await secureStorage.getItem(STORAGE_KEYS.TRANSACTIONS);
if (stored) {
const parsed = JSON.parse(stored) as TransactionRequest[];
// Filter expired transactions
const now = Date.now();
const valid = parsed.filter(tx => !tx.expiresAt || tx.expiresAt > now);
setTransactions(valid);
}
const method = await secureStorage.getItem(STORAGE_KEYS.DEFAULT_EXECUTION_METHOD);
if (method && Object.values(TransactionExecutionMethod).includes(method as TransactionExecutionMethod)) {
setDefaultExecutionMethod(method as TransactionExecutionMethod);
}
} catch (e) {
console.error("Failed to load transactions from storage", e);
}
}
};
loadTransactions();
}, []);
// Save transactions to secure storage
useEffect(() => {
const saveTransactions = async () => {
if (typeof window !== "undefined") {
try {
await secureStorage.setItem(STORAGE_KEYS.TRANSACTIONS, JSON.stringify(transactions));
} catch (e) {
console.error("Failed to save transactions to storage", e);
}
}
};
saveTransactions();
}, [transactions]);
// Save default execution method
useEffect(() => {
const saveMethod = async () => {
if (typeof window !== "undefined") {
try {
await secureStorage.setItem(STORAGE_KEYS.DEFAULT_EXECUTION_METHOD, defaultExecutionMethod);
} catch (e) {
console.error("Failed to save execution method", e);
}
}
};
saveMethod();
}, [defaultExecutionMethod]);
// Compute pending transactions
const pendingTransactions = transactions
.filter((tx) => tx.status === TransactionRequestStatus.PENDING || tx.status === TransactionRequestStatus.APPROVED)
.map((tx) => {
const txApprovals = approvals[tx.id] || [];
const approvalCount = txApprovals.filter((a) => a.approved).length;
const requiredApprovals = activeWallet?.threshold || 1;
const canExecute = approvalCount >= requiredApprovals;
return {
id: tx.id,
transaction: tx,
approvals: txApprovals,
approvalCount,
requiredApprovals,
canExecute,
};
});
const createTransaction = useCallback(
async (tx: Omit<TransactionRequest, "id" | "status" | "createdAt">): Promise<TransactionRequest> => {
// Validate transaction request
const validation = validateTransactionRequest(tx);
if (!validation.valid) {
throw new Error(`Invalid transaction: ${validation.errors.join(", ")}`);
}
// Rate limiting
const rateLimitKey = tx.from || "anonymous";
if (!rateLimiter.checkLimit(rateLimitKey)) {
throw new Error("Rate limit exceeded. Please wait before creating another transaction.");
}
// Get nonce if provider available
let nonce: number | undefined;
if (nonceManager && tx.from) {
try {
nonce = await nonceManager.getNextNonce(tx.from);
} catch (e) {
console.error("Failed to get nonce:", e);
}
}
// Generate transaction hash for deduplication
const txHash = tx.from && tx.to
? ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["address", "address", "uint256", "bytes", "uint256"],
[tx.from, tx.to, tx.value || "0", tx.data || "0x", nonce || 0]
)
)
: null;
// Check for duplicates
if (txHash) {
const existing = transactions.find(t => {
if (!t.from || !t.to) return false;
const existingHash = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["address", "address", "uint256", "bytes", "uint256"],
[t.from, t.to, t.value || "0", t.data || "0x", t.nonce || 0]
)
);
return existingHash === txHash;
});
if (existing) {
throw new Error("Duplicate transaction detected");
}
}
const newTx: TransactionRequest = {
...tx,
id: `tx_${Date.now()}_${generateSecureId()}`,
status: TransactionRequestStatus.PENDING,
createdAt: Date.now(),
method: (tx.method as TransactionExecutionMethod) || defaultExecutionMethod,
nonce,
expiresAt: Date.now() + SECURITY.TRANSACTION_EXPIRATION_MS,
};
setTransactions((prev) => [...prev, newTx]);
return newTx;
},
[defaultExecutionMethod, transactions, rateLimiter, nonceManager]
);
const updateTransaction = useCallback((id: string, updates: Partial<TransactionRequest>) => {
setTransactions((prev) =>
prev.map((tx) => (tx.id === id ? { ...tx, ...updates } : tx))
);
}, []);
const approveTransaction = useCallback(
async (transactionId: string, approver: string) => {
// Check lock
if (approvalLocks.get(transactionId)) {
throw new Error("Approval already in progress for this transaction");
}
const tx = transactions.find((t) => t.id === transactionId);
if (!tx) {
throw new Error("Transaction not found");
}
// Validate approver address
const { validateAddress } = await import("../utils/security");
const approverValidation = validateAddress(approver);
if (!approverValidation.valid) {
throw new Error(approverValidation.error || "Invalid approver address");
}
const validatedApprover = approverValidation.checksummed!;
// Verify approver is a wallet owner
if (activeWallet) {
const isOwner = activeWallet.owners.some(
o => o.toLowerCase() === validatedApprover.toLowerCase()
);
if (!isOwner) {
throw new Error("Unauthorized: Approver is not a wallet owner");
}
}
// Set lock
approvalLocks.set(transactionId, true);
try {
// Add approval atomically
setApprovals((prev) => {
const existing = prev[transactionId] || [];
// Check if already approved by this address
const alreadyApproved = existing.some(
(a) => a.approver.toLowerCase() === validatedApprover.toLowerCase() && a.approved
);
if (alreadyApproved) {
return prev; // No change needed
}
const newApproval: MultiSigApproval = {
transactionId,
approver: validatedApprover,
approved: true,
timestamp: Date.now(),
};
const updated = {
...prev,
[transactionId]: [...existing, newApproval],
};
// Check threshold atomically
const approvalCount = [...existing, newApproval].filter((a) => a.approved).length;
const requiredApprovals = activeWallet?.threshold || 1;
if (approvalCount >= requiredApprovals) {
// Update transaction status in next tick to avoid state update issues
setTimeout(() => {
updateTransaction(transactionId, {
status: TransactionRequestStatus.APPROVED,
});
}, 0);
}
return updated;
});
} finally {
// Release lock after a short delay
setTimeout(() => {
approvalLocks.delete(transactionId);
}, 100);
}
},
[transactions, activeWallet, updateTransaction]
);
const rejectTransaction = useCallback(
async (transactionId: string, approver: string) => {
// Add rejection
setApprovals((prev) => {
const existing = prev[transactionId] || [];
const alreadyRejected = existing.some(
(a) => a.approver.toLowerCase() === approver.toLowerCase() && !a.approved
);
if (alreadyRejected) {
return prev;
}
const newRejection: MultiSigApproval = {
transactionId,
approver,
approved: false,
timestamp: Date.now(),
};
return {
...prev,
[transactionId]: [...existing, newRejection],
};
});
updateTransaction(transactionId, {
status: TransactionRequestStatus.REJECTED,
});
},
[updateTransaction]
);
const executeTransaction = useCallback(
async (transactionId: string): Promise<string | null> => {
const tx = transactions.find((t) => t.id === transactionId);
if (!tx || !provider || !activeWallet) {
throw new Error("Transaction, provider, or wallet not available");
}
// Check if transaction is expired
if (tx.expiresAt && tx.expiresAt < Date.now()) {
updateTransaction(transactionId, {
status: TransactionRequestStatus.FAILED,
error: "Transaction expired",
});
throw new Error("Transaction has expired");
}
// Verify transaction is approved (if multi-sig)
if (activeWallet.threshold > 1) {
const txApprovals = approvals[transactionId] || [];
const approvalCount = txApprovals.filter((a) => a.approved).length;
if (approvalCount < activeWallet.threshold) {
throw new Error(`Insufficient approvals: ${approvalCount}/${activeWallet.threshold}`);
}
}
updateTransaction(transactionId, {
status: TransactionRequestStatus.EXECUTING,
});
try {
// For simulation method
if (tx.method === TransactionExecutionMethod.SIMULATION) {
const simulation = await simulateTransaction(tx, provider, activeWallet.address);
if (simulation.success) {
updateTransaction(transactionId, {
status: TransactionRequestStatus.SUCCESS,
executedAt: Date.now(),
});
return `simulated_${transactionId}`;
} else {
updateTransaction(transactionId, {
status: TransactionRequestStatus.FAILED,
error: simulation.error || "Simulation failed",
});
return null;
}
}
// For direct on-chain execution
if (tx.method === TransactionExecutionMethod.DIRECT_ONCHAIN) {
// Verify provider
const verifyProvider = (provider: any): boolean => {
return !!(provider.isMetaMask || provider.isCoinbaseWallet || provider.isWalletConnect);
};
let signer: ethers.Signer | null = null;
// Try to get signer from provider
if ((provider as any).getSigner) {
signer = (provider as any).getSigner();
}
// Fallback: try window.ethereum
if (!signer && typeof window !== "undefined" && (window as any).ethereum) {
const ethereum = (window as any).ethereum;
if (!verifyProvider(ethereum)) {
throw new Error("Unverified provider detected");
}
const web3Provider = new ethers.providers.Web3Provider(ethereum);
const accounts = await web3Provider.listAccounts();
// Verify account matches wallet
if (accounts[0]?.toLowerCase() !== activeWallet.address.toLowerCase()) {
throw new Error("Provider account does not match wallet address");
}
signer = web3Provider.getSigner();
}
if (!signer) {
throw new Error("No signer available for direct execution");
}
const txHash = await executeDirectTransaction(tx, provider, signer);
updateTransaction(transactionId, {
status: TransactionRequestStatus.SUCCESS,
hash: txHash,
executedAt: Date.now(),
});
// Refresh nonce after execution
if (nonceManager && tx.from) {
await nonceManager.refreshNonce(tx.from);
}
return txHash;
}
// For relayer method
if (tx.method === TransactionExecutionMethod.RELAYER) {
const relayer = DEFAULT_RELAYERS.find((r) => r.enabled);
if (!relayer) {
throw new Error("No enabled relayer available");
}
const txHash = await submitToRelayer(tx, relayer);
updateTransaction(transactionId, {
status: TransactionRequestStatus.SUCCESS,
hash: txHash,
executedAt: Date.now(),
});
return txHash;
}
return null;
} catch (error: any) {
updateTransaction(transactionId, {
status: TransactionRequestStatus.FAILED,
error: error.message || "Transaction execution failed",
});
throw error;
}
},
[transactions, provider, activeWallet, updateTransaction, approvals, nonceManager]
);
const estimateGas = useCallback(
async (tx: Partial<TransactionRequest>): Promise<GasEstimate | null> => {
if (!provider || !tx.to) {
return null;
}
try {
const gasLimit = await provider.estimateGas({
to: tx.to,
value: tx.value ? ethers.BigNumber.from(tx.value) : undefined,
data: tx.data || "0x",
});
// Validate gas limit
const MAX_GAS_LIMIT = ethers.BigNumber.from("10000000"); // 10M
if (gasLimit.gt(MAX_GAS_LIMIT)) {
throw new Error(`Gas limit ${gasLimit.toString()} exceeds maximum ${MAX_GAS_LIMIT.toString()}`);
}
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice || ethers.BigNumber.from(0);
const estimatedCost = gasLimit.mul(gasPrice);
return {
gasLimit: gasLimit.toString(),
gasPrice: gasPrice.toString(),
maxFeePerGas: feeData.maxFeePerGas?.toString(),
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas?.toString(),
estimatedCost: estimatedCost.toString(),
};
} catch (error: any) {
console.error("Failed to estimate gas", error);
throw new Error(error.message || "Gas estimation failed");
}
},
[provider]
);
return (
<TransactionContext.Provider
value={{
transactions,
pendingTransactions,
createTransaction,
updateTransaction,
approveTransaction,
rejectTransaction,
executeTransaction,
estimateGas,
defaultExecutionMethod,
setDefaultExecutionMethod,
}}
>
{children}
</TransactionContext.Provider>
);
};
export const useTransaction = () => useContext(TransactionContext);