feat: 完成 Mini Swap DEX AMM 项目开发
- 添加智能合约: TokenA, TokenB, MiniSwapAMM - 实现 AMM 流动性池功能 (x * y = k 公式) - 支持添加/移除流动性和代币交换 - 包含完整的测试套件 - 创建 React 前端界面,支持钱包连接 - 添加 Web3 集成和现代化 UI 设计 - 包含部署脚本和完整的项目配置
This commit is contained in:
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal 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
46
frontend/README.md
Normal 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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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
17894
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
frontend/public/contract-addresses.json
Normal file
6
frontend/public/contract-addresses.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"TokenA": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707",
|
||||
"TokenB": "0x0165878A594ca255338adfa4d48449f69242Eb8F",
|
||||
"MiniSwapAMM": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853",
|
||||
"deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal 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
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
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
421
frontend/src/App.css
Normal file
421
frontend/src/App.css
Normal 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;
|
||||
}
|
||||
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal 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
455
frontend/src/App.tsx
Normal 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;
|
||||
84
frontend/src/contracts/config.ts
Normal file
84
frontend/src/contracts/config.ts
Normal 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: ""
|
||||
};
|
||||
249
frontend/src/hooks/useWeb3.ts
Normal file
249
frontend/src/hooks/useWeb3.ts
Normal 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
13
frontend/src/index.css
Normal 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
19
frontend/src/index.tsx
Normal 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
1
frontend/src/logo.svg
Normal 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
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal 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;
|
||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal 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
26
frontend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user