- 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
531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
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);
|