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:
@@ -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
|
||||
|
||||
412
contexts/SmartWalletContext.tsx
Normal file
412
contexts/SmartWalletContext.tsx
Normal 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);
|
||||
530
contexts/TransactionContext.tsx
Normal file
530
contexts/TransactionContext.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user