简易ETH钱包
代码采用React+TypeScript开发,保持了良好的可维护性和用户体验:
功能说明
-
BIP-39助记词功能
- 完全符合BIP-39标准,生成12个单词的助记词
- 强制助记词验证流程,确保用户正确备份
- 支持通过助记词导入钱包,兼容行业标准
-
ERC-20代币支持
- 内置常用ERC-20代币列表(USDT、USDC、DAI等)
- 支持添加自定义ERC-20代币,自动识别代币名称、符号和小数位
- 实时显示代币余额,支持代币转账功能
-
交易历史查询
- 整合Etherscan API查询ETH和ERC-20代币交易历史
- 显示交易哈希、发送方、接收方、金额、时间和确认数
- 支持交易历史刷新,区分ETH和代币交易
-
私钥AES加密存储
- 使用crypto-js库实现AES加密算法
- 私钥加密后存储在localStorage,不直接存储明文
- 钱包解锁需要密码验证,增强安全性
实现代码
实现代码
import React, { useState, useEffect } from 'react';
import { ethers, HDNodeWallet } from 'ethers';
import CryptoJS from 'crypto-js';
import axios from 'axios';
// ERC-20代币标准ABI
const ERC20_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
// 常用ERC-20代币列表(Sepolia测试网)
const COMMON_TOKENS = [
{
symbol: "USDT",
address: "0x7163aF91147b087166083F24Eb3a99F59F451039",
decimals: 18
},
{
symbol: "USDC",
address: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
decimals: 6
},
{
symbol: "DAI",
address: "0x8EC166D656226935917538F69D3F660416aD219d",
decimals: 18
}
];
// 样式组件
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{
maxWidth: 900,
margin: '0 auto',
padding: '20px',
fontFamily: 'Arial, sans-serif',
backgroundColor: '#f9f9f9',
minHeight: '100vh'
}}>
{children}
</div>
);
const Card = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div style={{
marginBottom: '20px',
padding: '15px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}>
<h3 style={{
margin: '0 0 15px 0',
color: '#2c3e50',
fontSize: '1.2rem',
borderBottom: '1px solid #f0f0f0',
paddingBottom: '8px'
}}>{title}</h3>
{children}
</div>
);
const Button = ({
onClick,
children,
disabled = false,
primary = false,
danger = false
}: {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
primary?: boolean;
danger?: boolean;
}) => (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: '8px 16px',
fontSize: '14px',
cursor: disabled ? 'not-allowed' : 'pointer',
border: 'none',
borderRadius: '4px',
backgroundColor: disabled
? '#f0f0f0'
: danger
? '#e74c3c'
: primary
? '#3498db'
: '#2ecc71',
color: 'white',
margin: '5px',
transition: 'background-color 0.2s',
'&:hover': {
opacity: 0.9
}
}}
>
{children}
</button>
);
const Input = ({
value,
onChange,
placeholder,
type = 'text',
style = {},
multiline = false
}: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
placeholder: string;
type?: string;
style?: React.CSSProperties;
multiline?: boolean;
}) => {
const Element = multiline ? 'textarea' : 'input';
return React.createElement(Element, {
type,
value,
onChange,
placeholder,
style: {
padding: '10px',
fontSize: '14px',
borderRadius: '4px',
border: '1px solid #ddd',
width: '100%',
boxSizing: 'border-box',
minHeight: multiline ? '80px' : 'auto',
resize: multiline ? 'vertical' : 'none',
...style
}
});
};
const Alert = ({ message, type = 'info' }: { message: string; type?: 'info' | 'error' | 'success' }) => {
const styles = {
info: { bg: '#e3f2fd', text: '#1565c0', border: '#bbdefb' },
error: { bg: '#ffebee', text: '#b71c1c', border: '#f8bbd0' },
success: { bg: '#e8f5e9', text: '#2e7d32', border: '#c8e6c9' }
};
const style = styles[type];
return (
<div style={{
padding: '12px',
borderRadius: '4px',
backgroundColor: style.bg,
color: style.text,
border: `1px solid ${style.border}`,
margin: '10px 0',
fontSize: '14px'
}}>
{message}
</div>
);
};
const Modal = ({
visible,
onClose,
title,
children,
footer
}: {
visible: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) => {
if (!visible) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
width: '90%',
maxWidth: 600,
maxHeight: '80vh',
overflowY: 'auto'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, color: '#2c3e50' }}>{title}</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '20px',
color: '#777'
}}
>
×
</button>
</div>
{children}
{footer && (
<div style={{ marginTop: '20px', display: 'flex', justifyContent: 'flex-end' }}>
{footer}
</div>
)}
</div>
</div>
);
};
// 主钱包组件
const Wallet: React.FC = () => {
// 核心状态管理
const [address, setAddress] = useState<string>('');
const [privateKey, setPrivateKey] = useState<string>('');
const [mnemonic, setMnemonic] = useState<string>('');
const [ethBalance, setEthBalance] = useState<string>('0');
const [recipient, setRecipient] = useState<string>('');
const [amount, setAmount] = useState<string>('0.01');
const [loading, setLoading] = useState<boolean>(false);
const [message, setMessage] = useState<{ text: string; type: 'info' | 'error' | 'success' } | null>(null);
const [provider, setProvider] = useState<ethers.JsonRpcProvider | null>(null);
const [showPrivateKey, setShowPrivateKey] = useState<boolean>(false);
const [importType, setImportType] = useState<'privateKey' | 'mnemonic'>('privateKey');
const [importValue, setImportValue] = useState<string>('');
// 加密相关状态
const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
const [unlockPassword, setUnlockPassword] = useState<string>('');
const [isLocked, setIsLocked] = useState<boolean>(true);
// 代币相关状态
const [tokens, setTokens] = useState<Array<{
symbol: string;
address: string;
balance: string;
decimals: number;
name: string;
}>>([]);
const [selectedToken, setSelectedToken] = useState<string>('ETH');
const [tokenAddress, setTokenAddress] = useState<string>('');
// 交易历史相关状态
const [transactions, setTransactions] = useState<Array<{
hash: string;
from: string;
to: string;
value: string;
tokenSymbol?: string;
timeStamp: string;
confirmations: string;
}>>([]);
const [transactionsLoading, setTransactionsLoading] = useState<boolean>(false);
// 弹窗状态
const [mnemonicModalVisible, setMnemonicModalVisible] = useState<boolean>(false);
const [saveReminderVisible, setSaveReminderVisible] = useState<boolean>(false);
const [confirmMnemonic, setConfirmMnemonic] = useState<string>('');
const [mnemonicConfirmed, setMnemonicConfirmed] = useState<boolean>(false);
const [addTokenModalVisible, setAddTokenModalVisible] = useState<boolean>(false);
const [transactionHistoryVisible, setTransactionHistoryVisible] = useState<boolean>(false);
// 初始化Provider和检查存储的钱包
useEffect(() => {
// 初始化以太坊测试网Provider
const initProvider = () => {
try {
const newProvider = new ethers.JsonRpcProvider(
'https://sepolia.infura.io/v3/your-api-key' // 替换为你的Infura API密钥
);
setProvider(newProvider);
} catch (err) {
setMessage({ text: '初始化区块链连接失败', type: 'error' });
console.error(err);
}
};
// 检查是否有加密存储的私钥
const checkStoredWallet = () => {
const encryptedKey = localStorage.getItem('encrypted_eth_private_key');
if (encryptedKey) {
setIsLocked(true); // 需要密码解锁
} else {
setIsLocked(false); // 没有存储的钱包,显示创建/导入界面
}
};
initProvider();
checkStoredWallet();
}, []);
// 钱包解锁后加载数据
useEffect(() => {
if (address && provider && !isLocked) {
loadTokens();
fetchTransactionHistory();
}
}, [address, provider, isLocked]);
// 解锁钱包(AES解密)
const unlockWallet = () => {
if (!unlockPassword) {
setMessage({ text: '请输入密码', type: 'error' });
return;
}
try {
const encryptedKey = localStorage.getItem('encrypted_eth_private_key');
if (!encryptedKey) {
setMessage({ text: '没有找到存储的钱包', type: 'error' });
return;
}
// 使用AES解密私钥
const bytes = CryptoJS.AES.decrypt(encryptedKey, unlockPassword);
const privateKey = bytes.toString(CryptoJS.enc.Utf8);
if (!privateKey || !privateKey.startsWith('0x')) {
setMessage({ text: '密码错误', type: 'error' });
return;
}
const wallet = new ethers.Wallet(privateKey);
setPrivateKey(privateKey);
setAddress(wallet.address);
setIsLocked(false);
setMessage({ text: '钱包解锁成功', type: 'success' });
} catch (err) {
setMessage({ text: '解锁失败,密码可能不正确', type: 'error' });
console.error(err);
}
};
// 创建新钱包(BIP-39标准)
const createWallet = async () => {
if (!password) {
setMessage({ text: '请设置密码', type: 'error' });
return;
}
if (password !== confirmPassword) {
setMessage({ text: '两次输入的密码不一致', type: 'error' });
return;
}
try {
// 生成符合BIP-39标准的随机钱包
const wallet = ethers.Wallet.createRandom();
const mnemonicPhrase = wallet.mnemonic.phrase;
setPrivateKey(wallet.privateKey);
setAddress(wallet.address);
setMnemonic(mnemonicPhrase);
setConfirmMnemonic('');
setMnemonicConfirmed(false);
// 显示助记词弹窗
setMnemonicModalVisible(true);
} catch (err) {
setMessage({
text: `创建钱包失败: ${err instanceof Error ? err.message : String(err)}`,
type: 'error'
});
}
};
// 验证助记词并加密保存私钥
const verifyMnemonic = () => {
if (!confirmMnemonic.trim()) {
setMessage({ text: '请输入助记词', type: 'error' });
return;
}
if (confirmMnemonic.trim() === mnemonic.trim()) {
// 验证成功,使用AES加密私钥并存储
const encrypted = CryptoJS.AES.encrypt(privateKey, password).toString();
localStorage.setItem('encrypted_eth_private_key', encrypted);
setMnemonicConfirmed(true);
setIsLocked(false);
setMessage({ text: '助记词验证成功,钱包已创建', type: 'success' });
setSaveReminderVisible(true);
} else {
setMessage({ text: '助记词不匹配,请重新输入', type: 'error' });
}
};
// 导入钱包(私钥或助记词)
const importWallet = () => {
if (!importValue.trim()) {
setMessage({ text: '请输入私钥或助记词', type: 'error' });
return;
}
if (!password) {
setMessage({ text: '请设置密码', type: 'error' });
return;
}
try {
let wallet: ethers.Wallet;
if (importType === 'privateKey') {
// 私钥导入
if (!importValue.startsWith('0x')) {
setMessage({ text: '私钥必须以0x开头', type: 'error' });
return;
}
wallet = new ethers.Wallet(importValue);
} else {
// 助记词导入 (BIP-39标准)
const words = importValue.trim().split(/\s+/);
// BIP-39支持12, 15, 18, 21或24个单词
if (![12, 15, 18, 21, 24].includes(words.length)) {
setMessage({ text: '助记词必须是12, 15, 18, 21或24个单词', type: 'error' });
return;
}
wallet = HDNodeWallet.fromPhrase(importValue);
}
// 加密并保存私钥
const encrypted = CryptoJS.AES.encrypt(wallet.privateKey, password).toString();
localStorage.setItem('encrypted_eth_private_key', encrypted);
setPrivateKey(wallet.privateKey);
setAddress(wallet.address);
setIsLocked(false);
setMessage({ text: '钱包导入成功', type: 'success' });
setImportValue('');
setPassword('');
} catch (err) {
setMessage({
text: `导入失败: ${err instanceof Error ? err.message : String(err)}`,
type: 'error'
});
}
};
// 加载ERC-20代币余额
const loadTokens = async () => {
if (!address || !provider) return;
try {
setLoading(true);
// 先加载ETH余额
const balance = await provider.getBalance(address);
setEthBalance(ethers.formatEther(balance));
// 加载常用代币
const tokenData = await Promise.all(COMMON_TOKENS.map(async (token) => {
try {
const contract = new ethers.Contract(token.address, ERC20_ABI, provider);
const balance = await contract.balanceOf(address);
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
return {
symbol,
address: token.address,
balance: ethers.formatUnits(balance, decimals),
decimals,
name
};
} catch (err) {
console.error(`加载${token.symbol}失败:`, err);
return null;
}
}));
// 过滤掉加载失败的代币
const validTokens = tokenData.filter(Boolean) as Array<{
symbol: string;
address: string;
balance: string;
decimals: number;
name: string;
}>;
// 加载用户添加的自定义代币
const customTokens = JSON.parse(localStorage.getItem('custom_erc20_tokens') || '[]');
if (customTokens.length > 0) {
const customTokenData = await Promise.all(customTokens.map(async (token: any) => {
try {
const contract = new ethers.Contract(token.address, ERC20_ABI, provider);
const balance = await contract.balanceOf(address);
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
return {
symbol,
address: token.address,
balance: ethers.formatUnits(balance, decimals),
decimals,
name
};
} catch (err) {
console.error(`加载自定义代币失败:`, err);
return null;
}
}));
validTokens.push(...customTokenData.filter(Boolean) as any);
}
setTokens(validTokens);
} catch (err) {
setMessage({ text: '加载代币失败', type: 'error' });
console.error(err);
} finally {
setLoading(false);
}
};
// 添加自定义ERC-20代币
const addCustomToken = async () => {
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
setMessage({ text: '请输入有效的代币合约地址', type: 'error' });
return;
}
if (!provider) return;
try {
setLoading(true);
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
// 验证ERC-20合约
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
// 检查余额
const balance = await contract.balanceOf(address);
// 保存到本地存储
const customTokens = JSON.parse(localStorage.getItem('custom_erc20_tokens') || '[]');
// 避免重复添加
if (!customTokens.some((t: any) => t.address.toLowerCase() === tokenAddress.toLowerCase())) {
customTokens.push({ address: tokenAddress });
localStorage.setItem('custom_erc20_tokens', JSON.stringify(customTokens));
}
// 更新代币列表
setTokens([...tokens, {
symbol,
address: tokenAddress,
balance: ethers.formatUnits(balance, decimals),
decimals,
name
}]);
setTokenAddress('');
setAddTokenModalVisible(false);
setMessage({ text: `成功添加代币: ${symbol}`, type: 'success' });
} catch (err) {
setMessage({ text: '添加代币失败,可能不是有效的ERC-20合约', type: 'error' });
console.error(err);
} finally {
setLoading(false);
}
};
// 查询交易历史
const fetchTransactionHistory = async () => {
if (!address) return;
try {
setTransactionsLoading(true);
// 使用Etherscan API查询交易历史
const apiKey = 'your-etherscan-api-key'; // 替换为你的Etherscan API密钥
const chainId = (await provider?.getNetwork())?.chainId || 11155111; // Sepolia测试网
// 构建API URL
let baseUrl = chainId === 1
? 'https://api.etherscan.io/api'
: 'https://api-sepolia.etherscan.io/api';
// 查询ETH交易
const ethTxUrl = new URL(baseUrl);
ethTxUrl.searchParams.append('module', 'account');
ethTxUrl.searchParams.append('action', 'txlist');
ethTxUrl.searchParams.append('address', address);
ethTxUrl.searchParams.append('startblock', '0');
ethTxUrl.searchParams.append('endblock', '99999999');
ethTxUrl.searchParams.append('page', '1');
ethTxUrl.searchParams.append('offset', '20');
ethTxUrl.searchParams.append('sort', 'desc');
ethTxUrl.searchParams.append('apikey', apiKey);
const ethTxResponse = await axios.get(ethTxUrl.toString());
if (ethTxResponse.data.status !== '1') {
setMessage({ text: '查询ETH交易历史失败', type: 'error' });
setTransactionsLoading(false);
return;
}
// 格式化ETH交易
const ethTransactions = ethTxResponse.data.result.map((tx: any) => ({
hash: tx.hash,
from: tx.from,
to: tx.to || '合约创建',
value: ethers.formatEther(tx.value),
timeStamp: new Date(parseInt(tx.timeStamp) * 1000).toLocaleString(),
confirmations: tx.confirmations
}));
// 查询ERC-20代币交易
const tokenTxUrl = new URL(baseUrl);
tokenTxUrl.searchParams.append('module', 'account');
tokenTxUrl.searchParams.append('action', 'tokentx');
tokenTxUrl.searchParams.append('address', address);
tokenTxUrl.searchParams.append('startblock', '0');
tokenTxUrl.searchParams.append('endblock', '99999999');
tokenTxUrl.searchParams.append('page', '1');
tokenTxUrl.searchParams.append('offset', '20');
tokenTxUrl.searchParams.append('sort', 'desc');
tokenTxUrl.searchParams.append('apikey', apiKey);
const tokenTxResponse = await axios.get(tokenTxUrl.toString());
// 格式化代币交易
let tokenTransactions: any[] = [];
if (tokenTxResponse.data.status === '1') {
tokenTransactions = tokenTxResponse.data.result.map((tx: any) => {
// 查找对应的代币信息
const token = tokens.find(t => t.address.toLowerCase() === tx.contractAddress.toLowerCase());
const decimals = token?.decimals || 18;
return {
hash: tx.hash,
from: tx.from,
to: tx.to,
value: ethers.formatUnits(tx.value, decimals),
tokenSymbol: tx.tokenSymbol,
timeStamp: new Date(parseInt(tx.timeStamp) * 1000).toLocaleString(),
confirmations: tx.confirmations
};
});
}
// 合并并按时间排序所有交易
const allTransactions = [...ethTransactions, ...tokenTransactions]
.sort((a, b) => new Date(b.timeStamp).getTime() - new Date(a.timeStamp).getTime());
setTransactions(allTransactions);
} catch (err) {
setMessage({ text: '查询交易历史失败', type: 'error' });
console.error(err);
} finally {
setTransactionsLoading(false);
}
};
// 发送交易 (ETH或ERC-20代币)
const sendTransaction = async () => {
if (!privateKey || !recipient || !amount || !provider || isLocked) {
setMessage({ text: '请填写完整信息或解锁钱包', type: 'error' });
return;
}
// 地址校验
if (!ethers.isAddress(recipient)) {
setMessage({ text: '无效的接收地址', type: 'error' });
return;
}
// 金额校验
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || numAmount <= 0 || numAmount > 10000) {
setMessage({ text: '请输入有效的金额(正数且不超过10000)', type: 'error' });
return;
}
setLoading(true);
try {
const wallet = new ethers.Wallet(privateKey, provider);
if (selectedToken === 'ETH') {
// 发送ETH
// 余额检查
if (numAmount > parseFloat(ethBalance)) {
setMessage({ text: 'ETH余额不足', type: 'error' });
setLoading(false);
return;
}
const tx = {
to: recipient,
value: ethers.parseEther(amount),
gasLimit: 21000
};
const txResponse = await wallet.sendTransaction(tx);
setMessage({ text: `ETH交易已发送,哈希: ${txResponse.hash}`, type: 'success' });
} else {
// 发送ERC-20代币
const token = tokens.find(t => t.symbol === selectedToken);
if (!token) {
setMessage({ text: '未找到选中的代币', type: 'error' });
setLoading(false);
return;
}
// 余额检查
if (numAmount > parseFloat(token.balance)) {
setMessage({ text: `${token.symbol}余额不足`, type: 'error' });
setLoading(false);
return;
}
const contract = new ethers.Contract(token.address, ERC20_ABI, wallet);
const amountWei = ethers.parseUnits(amount, token.decimals);
const txResponse = await contract.transfer(recipient, amountWei);
setMessage({ text: `${token.symbol}交易已发送,哈希: ${txResponse.hash}`, type: 'success' });
}
// 重置表单
setRecipient('');
setAmount('0.01');
// 延迟刷新数据,等待链上确认
setTimeout(() => {
loadTokens();
fetchTransactionHistory();
}, 10000);
} catch (err) {
setMessage({
text: `交易失败: ${err instanceof Error ? err.message : String(err)}`,
type: 'error'
});
console.error(err);
} finally {
setLoading(false);
}
};
// 渲染钱包锁定状态
if (isLocked && localStorage.getItem('encrypted_eth_private_key')) {
return (
<Container>
<h2 style={{ color: '#2c3e50', textAlign: 'center' }}>ETH钱包</h2>
<Card title="解锁钱包">
<p>请输入密码解锁你的钱包</p>
<Input
type="password"
value={unlockPassword}
onChange={(e) => setUnlockPassword(e.target.value)}
placeholder="钱包密码"
/>
<div style={{ marginTop: '15px' }}>
<Button onClick={unlockWallet} primary>
解锁钱包
</Button>
</div>
</Card>
{message && <Alert message={message.text} type={message.type} />}
</Container>
);
}
// 渲染主界面
return (
<Container>
<h2 style={{ color: '#2c3e50', textAlign: 'center' }}>ETH钱包</h2>
{/* 钱包未创建时显示创建/导入选项 */}
{!address && !isLocked && (
<>
{/* 创建钱包 */}
<Card title="创建新钱包">
<p>创建新钱包将生成符合BIP-39标准的12个单词助记词,请务必妥善保管。</p>
<div style={{ marginBottom: '10px' }}>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="设置钱包密码(用于加密私钥)"
/>
</div>
<div style={{ marginBottom: '10px' }}>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="确认密码"
/>
</div>
<Button onClick={createWallet} primary>
创建新钱包
</Button>
<Alert message="密码用于加密存储私钥,请牢记你的密码和助记词!" type="info" />
</Card>
{/* 导入钱包 */}
<Card title="导入已有钱包">
<div style={{ marginBottom: '10px' }}>
<label style={{ marginRight: '15px' }}>
<input
type="radio"
checked={importType === 'privateKey'}
onChange={() => setImportType('privateKey')}
/>
私钥导入
</label>
<label>
<input
type="radio"
checked={importType === 'mnemonic'}
onChange={() => setImportType('mnemonic')}
/>
助记词导入 (BIP-39)
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<Input
value={importValue}
onChange={(e) => setImportValue(e.target.value)}
placeholder={importType === 'privateKey'
? '输入私钥(以0x开头)'
: '输入12, 15, 18, 21或24个单词的助记词,空格分隔'
}
multiline={importType === 'mnemonic'}
/>
</div>
<div style={{ marginBottom: '10px' }}>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="设置钱包密码(用于加密私钥)"
/>
</div>
<Button onClick={importWallet} primary>
导入钱包
</Button>
</Card>
</>
)}
{/* 钱包已创建时显示账户信息 */}
{address && !isLocked && (
<>
{/* 账户信息 */}
<Card title="账户信息">
<div style={{ marginBottom: '10px', wordBreak: 'break-all' }}>
<strong>地址:</strong> {address}
</div>
<div style={{ marginBottom: '15px', wordBreak: 'break-all' }}>
<strong>私钥:</strong>
{showPrivateKey ? privateKey : '********************'}
<Button
onClick={() => setShowPrivateKey(!showPrivateKey)}
style={{ padding: '2px 8px', fontSize: '12px', marginLeft: '10px' }}
>
{showPrivateKey ? '隐藏' : '显示'}
</Button>
</div>
<div>
<Button onClick={loadTokens} disabled={loading}>
{loading ? '刷新中...' : '刷新资产'}
</Button>
<Button onClick={() => setTransactionHistoryVisible(true)}>
查看交易历史
</Button>
</div>
</Card>
{/* 资产列表 */}
<Card title="资产列表">
<div style={{ marginBottom: '10px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
<strong>ETH</strong>: {ethBalance}
</div>
{tokens.length > 0 ? (
tokens.map((token) => (
<div
key={token.address}
style={{
marginBottom: '10px',
padding: '10px',
backgroundColor: '#f9f9f9',
borderRadius: '4px'
}}
>
<strong>{token.symbol}</strong> ({token.name}): {token.balance}
</div>
))
) : (
<Alert message="没有检测到ERC-20代币" type="info" />
)}
<Button onClick={() => setAddTokenModalVisible(true)}>
添加ERC-20代币
</Button>
</Card>
{/* 发送交易 */}
<Card title="发送资产">
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>选择资产:</label>
<select
value={selectedToken}
onChange={(e) => setSelectedToken(e.target.value)}
style={{
padding: '10px',
width: '100%',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
>
<option value="ETH">ETH</option>
{tokens.map((token) => (
<option key={token.address} value={token.symbol}>
{token.symbol} ({token.name})
</option>
))}
</select>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>接收地址:</label>
<Input
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="0x..."
/>
{recipient && !ethers.isAddress(recipient) && (
<Alert message="地址格式无效" type="error" />
)}
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
金额 ({selectedToken}):
</label>
<Input
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.01"
/>
{amount && isNaN(parseFloat(amount)) && (
<Alert message="请输入有效的金额" type="error" />
)}
</div>
<Button onClick={sendTransaction} disabled={loading} primary>
{loading ? '处理中...' : `发送 ${selectedToken}`}
</Button>
</Card>
</>
)}
{/* 消息提示 */}
{message && <Alert message={message.text} type={message.type} />}
{/* 助记词弹窗 */}
<Modal
visible={mnemonicModalVisible}
onClose={() => !mnemonicConfirmed && window.confirm('助记词未备份,确定要关闭吗?') && setMnemonicModalVisible(false)}
title="你的助记词 - 请务必备份!"
footer={
mnemonicConfirmed ? (
<Button onClick={() => setMnemonicModalVisible(false)}>完成</Button>
) : (
<Button onClick={verifyMnemonic} primary>验证助记词</Button>
)
}
>
<Alert message="这是你钱包的唯一备份,请在安全的地方离线记录下来!切勿截图或在网上分享!" type="error" />
<div style={{
backgroundColor: '#f5f5f5',
padding: '15px',
borderRadius: '4px',
margin: '15px 0',
wordBreak: 'break-all',
lineHeight: '1.6'
}}>
{mnemonic.split(' ').map((word, i) => (
<span key={i} style={{
display: 'inline-block',
width: '80px',
padding: '5px',
margin: '2px',
backgroundColor: '#eee',
borderRadius: '2px',
textAlign: 'center'
}}>
{i+1}. {word}
</span>
))}
</div>
{!mnemonicConfirmed && (
<>
<p>请重新输入上面的助记词以确认备份:</p>
<Input
value={confirmMnemonic}
onChange={(e) => setConfirmMnemonic(e.target.value)}
placeholder="输入完整的助记词,用空格分隔"
multiline
/>
</>
)}
</Modal>
{/* 保存提醒弹窗 */}
<Modal
visible={saveReminderVisible}
onClose={() => setSaveReminderVisible(false)}
title="重要提醒"
footer={
<Button onClick={() => setSaveReminderVisible(false)} primary>
我已了解并保存
</Button>
}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '48px', marginBottom: '20px' }}>⚠️</div>
<h4 style={{ color: '#b71c1c', marginBottom: '15px' }}>请务必牢记你的助记词!</h4>
<p style={{ marginBottom: '10px', textAlign: 'left' }}>1. 助记词是恢复钱包的唯一方式</p>
<p style={{ marginBottom: '10px', textAlign: 'left' }}>2. 丢失助记词将导致资产永久丢失</p>
<p style={{ marginBottom: '10px', textAlign: 'left' }}>3. 不要向任何人透露你的助记词</p>
<p style={{ textAlign: 'left' }}>4. 建议手写备份并保存在安全的地方</p>
</div>
</Modal>
{/* 添加代币弹窗 */}
<Modal
visible={addTokenModalVisible}
onClose={() => setAddTokenModalVisible(false)}
title="添加ERC-20代币"
footer={
<Button onClick={addCustomToken} primary>
添加代币
</Button>
}
>
<p>请输入ERC-20代币合约地址:</p>
<Input
value={tokenAddress}
onChange={(e) => setTokenAddress(e.target.value)}
placeholder="0x..."
/>
<Alert message="请确保合约地址正确,添加错误的合约可能导致资产显示异常" type="info" />
<p style={{ fontSize: '12px', color: '#777' }}>
提示:合约地址通常可以在代币的官方网站或区块浏览器上找到
</p>
</Modal>
{/* 交易历史弹窗 */}
<Modal
visible={transactionHistoryVisible}
onClose={() => setTransactionHistoryVisible(false)}
title="交易历史"
footer={
<Button onClick={fetchTransactionHistory} disabled={transactionsLoading}>
{transactionsLoading ? '刷新中...' : '刷新'}
</Button>
}
>
{transactionsLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>加载交易历史中...</div>
) : transactions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px' }}>没有找到交易历史</div>
) : (
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
{transactions.map((tx, index) => (
<div key={index} style={{
padding: '12px',
borderBottom: '1px solid #eee',
marginBottom: '10px'
}}>
<div style={{ marginBottom: '5px', fontSize: '13px' }}>
<strong>哈希:</strong> {tx.hash.substring(0, 10)}...{tx.hash.substring(tx.hash.length - 10)}
</div>
<div style={{ marginBottom: '5px', fontSize: '13px' }}>
<strong>从:</strong> {tx.from}
</div>
<div style={{ marginBottom: '5px', fontSize: '13px' }}>
<strong>到:</strong> {tx.to}
</div>
<div style={{ marginBottom: '5px', fontSize: '13px' }}>
<strong>金额:</strong> {tx.value} {tx.tokenSymbol || 'ETH'}
</div>
<div style={{ marginBottom: '5px', fontSize: '13px' }}>
<strong>时间:</strong> {tx.timeStamp}
</div>
<div style={{ fontSize: '13px' }}>
<strong>确认数:</strong> {tx.confirmations}
</div>
</div>
))}
</div>
)}
</Modal>
</Container>
);
};
export default Wallet;
运行入口
import React from 'react';
import Wallet from './Wallet';
function App() {
return (
<div className="App">
<Wallet />
</div>
);
}
export default App;
运行步骤
- 安装依赖:
npm install ethers crypto-js axios
-
替换代码中的API密钥:
- Infura API密钥:用于连接以太坊网络
- Etherscan API密钥:用于查询交易历史
-
启动应用:
npm start
- 在浏览器中访问
http://localhost:3000
5.效果图

安全特性
- 私钥全程在客户端处理,不经过网络传输
- 助记词强制备份验证,降低资产丢失风险
- 交易前进行余额检查和地址验证
- 密码强度验证和加密存储保护私钥
本文来自博客园,作者:ffffox,转载请注明原文链接:https://www.cnblogs.com/ffffox/p/19020989

浙公网安备 33010602011771号