feat: 完成 Mini Swap DEX AMM 项目开发

- 添加智能合约: TokenA, TokenB, MiniSwapAMM
- 实现 AMM 流动性池功能 (x * y = k 公式)
- 支持添加/移除流动性和代币交换
- 包含完整的测试套件
- 创建 React 前端界面,支持钱包连接
- 添加 Web3 集成和现代化 UI 设计
- 包含部署脚本和完整的项目配置
This commit is contained in:
2025-07-10 01:39:43 +08:00
parent 8a2454a950
commit 3faf89e0a1
38 changed files with 28192 additions and 1 deletions

23
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

46
frontend/README.md Normal file
View File

@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

17894
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"ethers": "^6.15.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"web3modal": "^1.9.12"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -0,0 +1,6 @@
{
"TokenA": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707",
"TokenB": "0x0165878A594ca255338adfa4d48449f69242Eb8F",
"MiniSwapAMM": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853",
"deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

421
frontend/src/App.css Normal file
View File

@ -0,0 +1,421 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 钱包连接页面 */
.connect-wallet {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
color: white;
padding: 2rem;
}
.connect-wallet h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(45deg, #fff, #f0f8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.connect-wallet p {
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.connect-button {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 1rem 2rem;
font-size: 1.1rem;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.connect-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
}
.connect-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error {
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
color: #ff6b6b;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
/* 头部 */
.header {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
color: white;
}
.header h1 {
font-size: 1.8rem;
font-weight: 700;
}
.wallet-info {
display: flex;
align-items: center;
gap: 1rem;
}
.wallet-info span {
font-size: 0.9rem;
opacity: 0.9;
}
.disconnect-button {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.disconnect-button:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 主要内容 */
.main {
flex: 1;
padding: 2rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
/* 标签页 */
.tabs {
display: flex;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.5rem;
margin-bottom: 2rem;
backdrop-filter: blur(20px);
}
.tab {
flex: 1;
background: transparent;
border: none;
color: white;
padding: 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.tab:hover {
background: rgba(255, 255, 255, 0.1);
}
.tab.active {
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
/* 面板 */
.swap-panel, .liquidity-panel {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
backdrop-filter: blur(20px);
margin-bottom: 2rem;
}
.swap-panel h2, .liquidity-panel h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.5rem;
text-align: center;
}
.liquidity-panel h3 {
color: #333;
margin-bottom: 1rem;
font-size: 1.2rem;
}
/* 交换表单 */
.swap-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-group label {
font-weight: 600;
color: #555;
font-size: 0.9rem;
}
.token-input {
display: flex;
gap: 0.5rem;
}
.token-input input {
flex: 1;
padding: 1rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
font-size: 1.1rem;
background: white;
transition: border-color 0.3s ease;
}
.token-input input:focus {
outline: none;
border-color: #667eea;
}
.token-input select {
padding: 1rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
background: white;
font-size: 1rem;
color: #333;
cursor: pointer;
}
.token-input select:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.input-group small {
color: #666;
font-size: 0.8rem;
margin-left: 0.5rem;
}
.swap-direction {
align-self: center;
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
width: 48px;
height: 48px;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
transition: all 0.3s ease;
margin: 0.5rem 0;
}
.swap-direction:hover {
transform: rotate(180deg) scale(1.1);
}
.action-button {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
}
.action-button:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* 池信息 */
.pool-info {
background: rgba(102, 126, 234, 0.1);
border-radius: 12px;
padding: 1.5rem;
margin-top: 2rem;
}
.pool-info h3 {
color: #333;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.pool-info p {
color: #555;
margin-bottom: 0.5rem;
font-weight: 500;
}
/* 流动性管理 */
.liquidity-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
border: 1px solid rgba(102, 126, 234, 0.1);
}
.liquidity-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pool-stats {
background: rgba(118, 75, 162, 0.1);
border-radius: 12px;
padding: 1.5rem;
margin-top: 1rem;
}
.pool-stats h3 {
color: #333;
margin-bottom: 1rem;
}
.pool-stats p {
color: #555;
margin-bottom: 0.5rem;
font-weight: 500;
}
/* 测试代币部分 */
.faucet-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
backdrop-filter: blur(20px);
text-align: center;
}
.faucet-section h3 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.3rem;
}
.faucet-button {
background: linear-gradient(45deg, #28a745, #20c997);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 10px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin: 0 0.5rem;
}
.faucet-button:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4);
}
/* 响应式设计 */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.wallet-info {
flex-direction: column;
gap: 0.5rem;
}
.main {
padding: 1rem;
}
.swap-panel, .liquidity-panel, .faucet-section {
padding: 1.5rem;
}
.connect-wallet h1 {
font-size: 2rem;
}
.token-input {
flex-direction: column;
}
.faucet-button {
display: block;
width: 100%;
margin: 0.5rem 0;
}
}
/* 加载动画 */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.action-button:disabled {
animation: pulse 1.5s infinite;
}

View File

@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

455
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,455 @@
import React, { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import { useWeb3 } from './hooks/useWeb3';
import './App.css';
interface TokenBalance {
tokenA: string;
tokenB: string;
lpToken: string;
}
interface PoolInfo {
reserveA: string;
reserveB: string;
totalSupply: string;
userLpBalance: string;
}
function App() {
const {
isConnected,
account,
chainId,
isLoading,
error,
contracts,
connectWallet,
disconnectWallet,
switchNetwork
} = useWeb3();
const [activeTab, setActiveTab] = useState<'swap' | 'liquidity'>('swap');
const [balances, setBalances] = useState<TokenBalance>({
tokenA: '0',
tokenB: '0',
lpToken: '0'
});
const [poolInfo, setPoolInfo] = useState<PoolInfo>({
reserveA: '0',
reserveB: '0',
totalSupply: '0',
userLpBalance: '0'
});
// 交换相关状态
const [swapFrom, setSwapFrom] = useState<'A' | 'B'>('A');
const [swapAmountIn, setSwapAmountIn] = useState('');
const [swapAmountOut, setSwapAmountOut] = useState('');
const [swapLoading, setSwapLoading] = useState(false);
// 流动性相关状态
const [liquidityAmountA, setLiquidityAmountA] = useState('');
const [liquidityAmountB, setLiquidityAmountB] = useState('');
const [removeLiquidityAmount, setRemoveLiquidityAmount] = useState('');
const [liquidityLoading, setLiquidityLoading] = useState(false);
// 加载余额和池信息
const loadData = async () => {
if (!contracts.tokenA || !contracts.tokenB || !contracts.miniSwapAMM || !account) return;
try {
// 获取代币余额
const balanceA = await contracts.tokenA.balanceOf(account);
const balanceB = await contracts.tokenB.balanceOf(account);
const balanceLP = await contracts.miniSwapAMM.balanceOf(account);
setBalances({
tokenA: ethers.formatEther(balanceA),
tokenB: ethers.formatEther(balanceB),
lpToken: ethers.formatEther(balanceLP)
});
// 获取池信息
const [reserveA, reserveB] = await contracts.miniSwapAMM.getReserves();
const totalSupply = await contracts.miniSwapAMM.totalSupply();
setPoolInfo({
reserveA: ethers.formatEther(reserveA),
reserveB: ethers.formatEther(reserveB),
totalSupply: ethers.formatEther(totalSupply),
userLpBalance: ethers.formatEther(balanceLP)
});
} catch (error) {
console.error('加载数据失败:', error);
}
};
useEffect(() => {
if (isConnected && contracts.tokenA) {
loadData();
}
}, [isConnected, contracts, account]);
// 计算交换输出
const calculateSwapOutput = async (amountIn: string) => {
if (!contracts.miniSwapAMM || !amountIn || parseFloat(amountIn) <= 0) {
setSwapAmountOut('');
return;
}
try {
const amountInWei = ethers.parseEther(amountIn);
const reserveIn = ethers.parseEther(swapFrom === 'A' ? poolInfo.reserveA : poolInfo.reserveB);
const reserveOut = ethers.parseEther(swapFrom === 'A' ? poolInfo.reserveB : poolInfo.reserveA);
if (reserveIn === 0n || reserveOut === 0n) {
setSwapAmountOut('0');
return;
}
const amountOut = await contracts.miniSwapAMM.getAmountOut(amountInWei, reserveIn, reserveOut);
setSwapAmountOut(ethers.formatEther(amountOut));
} catch (error) {
console.error('计算交换输出失败:', error);
setSwapAmountOut('');
}
};
useEffect(() => {
calculateSwapOutput(swapAmountIn);
}, [swapAmountIn, swapFrom, poolInfo]);
// 执行代币交换
const handleSwap = async () => {
if (!contracts.tokenA || !contracts.tokenB || !contracts.miniSwapAMM || !swapAmountIn || !swapAmountOut) return;
setSwapLoading(true);
try {
const amountInWei = ethers.parseEther(swapAmountIn);
const amountOutMinWei = ethers.parseEther((parseFloat(swapAmountOut) * 0.95).toString()); // 5% 滑点
const tokenContract = swapFrom === 'A' ? contracts.tokenA : contracts.tokenB;
// 首先批准代币转移
const approveTx = await tokenContract.approve(await contracts.miniSwapAMM.getAddress(), amountInWei);
await approveTx.wait();
// 执行交换
const swapTx = swapFrom === 'A'
? await contracts.miniSwapAMM.swapAForB(amountInWei, amountOutMinWei)
: await contracts.miniSwapAMM.swapBForA(amountInWei, amountOutMinWei);
await swapTx.wait();
// 重新加载数据
await loadData();
setSwapAmountIn('');
setSwapAmountOut('');
alert('交换成功!');
} catch (error: any) {
console.error('交换失败:', error);
alert('交换失败: ' + (error.reason || error.message));
}
setSwapLoading(false);
};
// 添加流动性
const handleAddLiquidity = async () => {
if (!contracts.tokenA || !contracts.tokenB || !contracts.miniSwapAMM || !liquidityAmountA || !liquidityAmountB) return;
setLiquidityLoading(true);
try {
const amountAWei = ethers.parseEther(liquidityAmountA);
const amountBWei = ethers.parseEther(liquidityAmountB);
// 批准代币转移
const approveATx = await contracts.tokenA.approve(await contracts.miniSwapAMM.getAddress(), amountAWei);
await approveATx.wait();
const approveBTx = await contracts.tokenB.approve(await contracts.miniSwapAMM.getAddress(), amountBWei);
await approveBTx.wait();
// 添加流动性
const addTx = await contracts.miniSwapAMM.addLiquidity(
amountAWei,
amountBWei,
ethers.parseEther((parseFloat(liquidityAmountA) * 0.95).toString()),
ethers.parseEther((parseFloat(liquidityAmountB) * 0.95).toString())
);
await addTx.wait();
// 重新加载数据
await loadData();
setLiquidityAmountA('');
setLiquidityAmountB('');
alert('添加流动性成功!');
} catch (error: any) {
console.error('添加流动性失败:', error);
alert('添加流动性失败: ' + (error.reason || error.message));
}
setLiquidityLoading(false);
};
// 移除流动性
const handleRemoveLiquidity = async () => {
if (!contracts.miniSwapAMM || !removeLiquidityAmount) return;
setLiquidityLoading(true);
try {
const liquidityWei = ethers.parseEther(removeLiquidityAmount);
const removeTx = await contracts.miniSwapAMM.removeLiquidity(
liquidityWei,
0, // 最小数量设为0生产环境应该计算合理的最小值
0
);
await removeTx.wait();
// 重新加载数据
await loadData();
setRemoveLiquidityAmount('');
alert('移除流动性成功!');
} catch (error: any) {
console.error('移除流动性失败:', error);
alert('移除流动性失败: ' + (error.reason || error.message));
}
setLiquidityLoading(false);
};
// 获取测试代币
const handleFaucet = async (token: 'A' | 'B') => {
const contract = token === 'A' ? contracts.tokenA : contracts.tokenB;
if (!contract) return;
try {
const tx = await contract.faucet(ethers.parseEther('1000'));
await tx.wait();
await loadData();
alert(`成功获取 1000 个 Token${token}`);
} catch (error: any) {
console.error('获取测试代币失败:', error);
alert('获取测试代币失败: ' + (error.reason || error.message));
}
};
if (!isConnected) {
return (
<div className="app">
<div className="connect-wallet">
<h1>Mini Swap DEX</h1>
<p></p>
{error && <div className="error">{error}</div>}
<button
onClick={connectWallet}
disabled={isLoading}
className="connect-button"
>
{isLoading ? '连接中...' : '连接钱包'}
</button>
</div>
</div>
);
}
if (chainId !== 31337) {
return (
<div className="app">
<div className="connect-wallet">
<h1></h1>
<p> (Chain ID: 31337)</p>
<button onClick={switchNetwork} className="connect-button">
</button>
</div>
</div>
);
}
return (
<div className="app">
<header className="header">
<h1>Mini Swap DEX</h1>
<div className="wallet-info">
<span>: {parseFloat(balances.tokenA).toFixed(2)} TKA, {parseFloat(balances.tokenB).toFixed(2)} TKB</span>
<span>{account.slice(0, 6)}...{account.slice(-4)}</span>
<button onClick={disconnectWallet} className="disconnect-button"></button>
</div>
</header>
<main className="main">
<div className="tabs">
<button
className={activeTab === 'swap' ? 'tab active' : 'tab'}
onClick={() => setActiveTab('swap')}
>
</button>
<button
className={activeTab === 'liquidity' ? 'tab active' : 'tab'}
onClick={() => setActiveTab('liquidity')}
>
</button>
</div>
{activeTab === 'swap' && (
<div className="swap-panel">
<h2></h2>
<div className="swap-form">
<div className="input-group">
<label></label>
<div className="token-input">
<input
type="number"
placeholder="0.0"
value={swapAmountIn}
onChange={(e) => setSwapAmountIn(e.target.value)}
/>
<select value={swapFrom} onChange={(e) => setSwapFrom(e.target.value as 'A' | 'B')}>
<option value="A">TKA</option>
<option value="B">TKB</option>
</select>
</div>
<small>: {swapFrom === 'A' ? balances.tokenA : balances.tokenB}</small>
</div>
<button
className="swap-direction"
onClick={() => setSwapFrom(swapFrom === 'A' ? 'B' : 'A')}
>
</button>
<div className="input-group">
<label></label>
<div className="token-input">
<input
type="number"
placeholder="0.0"
value={swapAmountOut}
readOnly
/>
<select value={swapFrom === 'A' ? 'B' : 'A'} disabled>
<option value="A">TKA</option>
<option value="B">TKB</option>
</select>
</div>
<small>: {swapFrom === 'A' ? balances.tokenB : balances.tokenA}</small>
</div>
<button
onClick={handleSwap}
disabled={swapLoading || !swapAmountIn || !swapAmountOut}
className="action-button"
>
{swapLoading ? '交换中...' : '交换'}
</button>
</div>
<div className="pool-info">
<h3></h3>
<p>TKA : {parseFloat(poolInfo.reserveA).toFixed(2)}</p>
<p>TKB : {parseFloat(poolInfo.reserveB).toFixed(2)}</p>
<p>: {poolInfo.reserveA !== '0' && poolInfo.reserveB !== '0'
? (parseFloat(poolInfo.reserveB) / parseFloat(poolInfo.reserveA)).toFixed(4)
: '0'} TKB/TKA</p>
</div>
</div>
)}
{activeTab === 'liquidity' && (
<div className="liquidity-panel">
<h2></h2>
<div className="liquidity-section">
<h3></h3>
<div className="liquidity-form">
<div className="input-group">
<label>TKA </label>
<input
type="number"
placeholder="0.0"
value={liquidityAmountA}
onChange={(e) => setLiquidityAmountA(e.target.value)}
/>
<small>: {balances.tokenA}</small>
</div>
<div className="input-group">
<label>TKB </label>
<input
type="number"
placeholder="0.0"
value={liquidityAmountB}
onChange={(e) => setLiquidityAmountB(e.target.value)}
/>
<small>: {balances.tokenB}</small>
</div>
<button
onClick={handleAddLiquidity}
disabled={liquidityLoading || !liquidityAmountA || !liquidityAmountB}
className="action-button"
>
{liquidityLoading ? '添加中...' : '添加流动性'}
</button>
</div>
</div>
<div className="liquidity-section">
<h3></h3>
<div className="liquidity-form">
<div className="input-group">
<label>LP </label>
<input
type="number"
placeholder="0.0"
value={removeLiquidityAmount}
onChange={(e) => setRemoveLiquidityAmount(e.target.value)}
/>
<small>LP : {balances.lpToken}</small>
</div>
<button
onClick={handleRemoveLiquidity}
disabled={liquidityLoading || !removeLiquidityAmount}
className="action-button"
>
{liquidityLoading ? '移除中...' : '移除流动性'}
</button>
</div>
</div>
<div className="pool-stats">
<h3></h3>
<p>LP : {parseFloat(balances.lpToken).toFixed(6)}</p>
<p> LP : {parseFloat(poolInfo.totalSupply).toFixed(6)}</p>
<p>: {poolInfo.totalSupply !== '0'
? ((parseFloat(balances.lpToken) / parseFloat(poolInfo.totalSupply)) * 100).toFixed(4)
: '0'}%</p>
</div>
</div>
)}
<div className="faucet-section">
<h3></h3>
<button onClick={() => handleFaucet('A')} className="faucet-button">
1000 TKA
</button>
<button onClick={() => handleFaucet('B')} className="faucet-button">
1000 TKB
</button>
</div>
</main>
</div>
);
}
export default App;

View File

@ -0,0 +1,84 @@
// 智能合约配置
export const CONTRACT_CONFIG = {
chainId: 31337, // Hardhat 本地网络
rpcUrl: "http://127.0.0.1:8545",
};
// TokenA ABI
export const TOKEN_A_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
"function faucet(uint256 amount)",
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)"
];
// TokenB ABI (相同结构)
export const TOKEN_B_ABI = TOKEN_A_ABI;
// MiniSwapAMM ABI
export const MINI_SWAP_AMM_ABI = [
// ERC20 函数
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
// AMM 特定函数
"function tokenA() view returns (address)",
"function tokenB() view returns (address)",
"function getReserves() view returns (uint256, uint256)",
"function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) view returns (uint256)",
"function addLiquidity(uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin) returns (uint256, uint256, uint256)",
"function removeLiquidity(uint256 liquidity, uint256 amountAMin, uint256 amountBMin) returns (uint256, uint256)",
"function swapAForB(uint256 amountAIn, uint256 amountBOutMin) returns (uint256)",
"function swapBForA(uint256 amountBIn, uint256 amountAOutMin) returns (uint256)",
// 事件
"event AddLiquidity(address indexed provider, uint256 amountA, uint256 amountB, uint256 liquidity)",
"event RemoveLiquidity(address indexed provider, uint256 amountA, uint256 amountB, uint256 liquidity)",
"event Swap(address indexed trader, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut)"
];
// 合约地址接口
export interface ContractAddresses {
TokenA: string;
TokenB: string;
MiniSwapAMM: string;
deployer: string;
}
// 从部署文件中加载合约地址
export const loadContractAddresses = async (): Promise<ContractAddresses | null> => {
try {
const response = await fetch('/contract-addresses.json');
if (!response.ok) {
console.warn('合约地址文件未找到,请先部署智能合约');
return null;
}
return await response.json();
} catch (error) {
console.warn('无法加载合约地址:', error);
return null;
}
};
// 默认合约地址(如果文件不存在)
export const DEFAULT_ADDRESSES: ContractAddresses = {
TokenA: "",
TokenB: "",
MiniSwapAMM: "",
deployer: ""
};

View File

@ -0,0 +1,249 @@
import { useState, useEffect, useCallback } from 'react';
import { ethers, BrowserProvider, Contract } from 'ethers';
import {
CONTRACT_CONFIG,
TOKEN_A_ABI,
TOKEN_B_ABI,
MINI_SWAP_AMM_ABI,
ContractAddresses,
loadContractAddresses,
DEFAULT_ADDRESSES
} from '../contracts/config';
declare global {
interface Window {
ethereum?: any;
}
}
export interface Web3State {
provider: BrowserProvider | null;
signer: ethers.JsonRpcSigner | null;
account: string;
chainId: number | null;
isConnected: boolean;
isLoading: boolean;
error: string | null;
}
export interface ContractInstances {
tokenA: Contract | null;
tokenB: Contract | null;
miniSwapAMM: Contract | null;
}
export const useWeb3 = () => {
const [web3State, setWeb3State] = useState<Web3State>({
provider: null,
signer: null,
account: '',
chainId: null,
isConnected: false,
isLoading: false,
error: null,
});
const [contracts, setContracts] = useState<ContractInstances>({
tokenA: null,
tokenB: null,
miniSwapAMM: null,
});
const [contractAddresses, setContractAddresses] = useState<ContractAddresses>(DEFAULT_ADDRESSES);
// 加载合约地址
useEffect(() => {
const loadAddresses = async () => {
const addresses = await loadContractAddresses();
if (addresses) {
setContractAddresses(addresses);
}
};
loadAddresses();
}, []);
// 连接钱包
const connectWallet = useCallback(async () => {
if (!window.ethereum) {
setWeb3State(prev => ({
...prev,
error: '请安装 MetaMask 钱包'
}));
return;
}
try {
setWeb3State(prev => ({ ...prev, isLoading: true, error: null }));
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const signer = await provider.getSigner();
const network = await provider.getNetwork();
setWeb3State({
provider,
signer,
account: accounts[0],
chainId: Number(network.chainId),
isConnected: true,
isLoading: false,
error: null,
});
// 检查网络
if (Number(network.chainId) !== CONTRACT_CONFIG.chainId) {
setWeb3State(prev => ({
...prev,
error: `请切换到本地测试网络 (Chain ID: ${CONTRACT_CONFIG.chainId})`
}));
}
} catch (error: any) {
setWeb3State(prev => ({
...prev,
isLoading: false,
error: error.message || '连接钱包失败'
}));
}
}, []);
// 断开连接
const disconnectWallet = useCallback(() => {
setWeb3State({
provider: null,
signer: null,
account: '',
chainId: null,
isConnected: false,
isLoading: false,
error: null,
});
setContracts({
tokenA: null,
tokenB: null,
miniSwapAMM: null,
});
}, []);
// 初始化合约实例
useEffect(() => {
if (web3State.signer && contractAddresses.TokenA && contractAddresses.TokenB && contractAddresses.MiniSwapAMM) {
try {
const tokenA = new Contract(contractAddresses.TokenA, TOKEN_A_ABI, web3State.signer);
const tokenB = new Contract(contractAddresses.TokenB, TOKEN_B_ABI, web3State.signer);
const miniSwapAMM = new Contract(contractAddresses.MiniSwapAMM, MINI_SWAP_AMM_ABI, web3State.signer);
setContracts({
tokenA,
tokenB,
miniSwapAMM,
});
} catch (error: any) {
console.error('初始化合约失败:', error);
setWeb3State(prev => ({
...prev,
error: '初始化合约失败: ' + error.message
}));
}
}
}, [web3State.signer, contractAddresses]);
// 监听账户变化
useEffect(() => {
if (window.ethereum) {
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnectWallet();
} else {
setWeb3State(prev => ({ ...prev, account: accounts[0] }));
}
};
const handleChainChanged = (chainId: string) => {
setWeb3State(prev => ({ ...prev, chainId: parseInt(chainId, 16) }));
window.location.reload(); // 重新加载页面以重新初始化
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
};
}
}, [disconnectWallet]);
// 检查是否已连接
useEffect(() => {
const checkConnection = async () => {
if (window.ethereum) {
try {
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.listAccounts();
if (accounts.length > 0) {
const signer = await provider.getSigner();
const network = await provider.getNetwork();
setWeb3State({
provider,
signer,
account: accounts[0].address,
chainId: Number(network.chainId),
isConnected: true,
isLoading: false,
error: null,
});
}
} catch (error) {
console.log('未连接到钱包');
}
}
};
checkConnection();
}, []);
// 切换网络
const switchNetwork = useCallback(async () => {
if (!window.ethereum) return;
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${CONTRACT_CONFIG.chainId.toString(16)}` }],
});
} catch (error: any) {
if (error.code === 4902) {
// 网络不存在,添加网络
try {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: `0x${CONTRACT_CONFIG.chainId.toString(16)}`,
chainName: 'Hardhat Local',
nativeCurrency: {
name: 'ETH',
symbol: 'ETH',
decimals: 18,
},
rpcUrls: [CONTRACT_CONFIG.rpcUrl],
}],
});
} catch (addError) {
console.error('添加网络失败:', addError);
}
}
}
}, []);
return {
...web3State,
contracts,
contractAddresses,
connectWallet,
disconnectWallet,
switchNetwork,
};
};

13
frontend/src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

19
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
frontend/src/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}