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

142
.gitignore vendored Normal file
View File

@ -0,0 +1,142 @@
# Dependencies
node_modules/
frontend/node_modules/
# Build outputs
dist/
build/
frontend/build/
frontend/dist/
# Hardhat files
cache/
artifacts/
typechain-types/
# Contract deployment addresses (optional, 根据需要决定是否忽略)
# contract-addresses.json
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
.nyc_output
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary folders
tmp/
temp/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files - 只忽略根目录的 public 文件夹,不影响 frontend/public
/public
# Storybook build outputs
.out
.storybook-out
# Rollup.js default build output
dist/
# Uncomment if using Webpack
# webpack-stats.json
# TypeScript compiled output
*.tsbuildinfo
# Optional stylelint cache
.stylelintcache
# SvelteKit build / generate output
.svelte-kit
# Local netlify folder
.netlify
# Hardhat Network files (if using Hardhat Network)
hardhat-network-helpers/
# Test files (optional)
# test-results/
# playwright-report/
# Solidity coverage files
coverage.json
coverage/
# Certora prover output
.certora_verify_cache/
.certora_config/
# Gas reporter output
gas-report.txt
# Slither analysis output
slither-report.json
# Echidna
crytic-export/

1
1
View File

@ -1 +0,0 @@
.

57
README.md Normal file
View File

@ -0,0 +1,57 @@
# Mini Swap DEX
一个简单的去中心化交易所 (DEX) - AMM 模型
## 功能特性
- 自动化做市商 (AMM) 流动性池
- 支持两种 ERC-20 代币的交易对
- 添加/移除流动性功能
- 基于 x * y = k 公式的价格发现机制
- 流动性提供者 (LP) 代币奖励
- React 前端界面
## 技术栈
- **智能合约**: Solidity, Hardhat, OpenZeppelin
- **前端**: React, Web3.js/Ethers.js
- **测试网络**: Hardhat 本地网络
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 编译智能合约
```bash
npm run compile
```
### 3. 启动本地测试网络
```bash
npm run node
```
### 4. 部署智能合约
```bash
npm run deploy
```
### 5. 启动前端
```bash
npm run dev
```
## 项目结构
```
mini-swap/
├── contracts/ # 智能合约
├── test/ # 测试文件
├── scripts/ # 部署脚本
├── frontend/ # React 前端
├── hardhat.config.js # Hardhat 配置
└── package.json # 项目配置
```

6
contract-addresses.json Normal file
View File

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

1
contracts/.gitkeep Normal file
View File

@ -0,0 +1 @@
# 智能合约目录

239
contracts/MiniSwapAMM.sol Normal file
View File

@ -0,0 +1,239 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
/**
* @title MiniSwapAMM
* @dev 简单的自动做市商 (AMM) 实现,基于 x * y = k 公式
*/
contract MiniSwapAMM is ERC20, ReentrancyGuard {
IERC20 public immutable tokenA;
IERC20 public immutable tokenB;
uint256 public reserveA;
uint256 public reserveB;
uint256 private constant MINIMUM_LIQUIDITY = 10**3;
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
);
constructor(address _tokenA, address _tokenB) ERC20("MiniSwap LP Token", "MSLP") {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
/**
* @dev 添加流动性
* @param amountADesired 期望添加的 tokenA 数量
* @param amountBDesired 期望添加的 tokenB 数量
* @param amountAMin 最小添加的 tokenA 数量
* @param amountBMin 最小添加的 tokenB 数量
* @return amountA 实际添加的 tokenA 数量
* @return amountB 实际添加的 tokenB 数量
* @return liquidity 获得的 LP 代币数量
*/
function addLiquidity(
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) external nonReentrant returns (uint256 amountA, uint256 amountB, uint256 liquidity) {
(amountA, amountB) = _calculateOptimalAmounts(
amountADesired,
amountBDesired,
amountAMin,
amountBMin
);
// 转移代币到合约
tokenA.transferFrom(msg.sender, address(this), amountA);
tokenB.transferFrom(msg.sender, address(this), amountB);
liquidity = _mintLiquidity(amountA, amountB);
emit AddLiquidity(msg.sender, amountA, amountB, liquidity);
}
/**
* @dev 移除流动性
* @param liquidity 要移除的 LP 代币数量
* @param amountAMin 最小获得的 tokenA 数量
* @param amountBMin 最小获得的 tokenB 数量
* @return amountA 获得的 tokenA 数量
* @return amountB 获得的 tokenB 数量
*/
function removeLiquidity(
uint256 liquidity,
uint256 amountAMin,
uint256 amountBMin
) external nonReentrant returns (uint256 amountA, uint256 amountB) {
require(liquidity > 0, "MiniSwapAMM: INSUFFICIENT_LIQUIDITY");
uint256 totalSupply = totalSupply();
amountA = (liquidity * reserveA) / totalSupply;
amountB = (liquidity * reserveB) / totalSupply;
require(amountA >= amountAMin, "MiniSwapAMM: INSUFFICIENT_A_AMOUNT");
require(amountB >= amountBMin, "MiniSwapAMM: INSUFFICIENT_B_AMOUNT");
_burn(msg.sender, liquidity);
tokenA.transfer(msg.sender, amountA);
tokenB.transfer(msg.sender, amountB);
_updateReserves();
emit RemoveLiquidity(msg.sender, amountA, amountB, liquidity);
}
/**
* @dev 交换代币 A 到代币 B
* @param amountAIn 输入的 tokenA 数量
* @param amountBOutMin 最小输出的 tokenB 数量
* @return amountBOut 实际输出的 tokenB 数量
*/
function swapAForB(uint256 amountAIn, uint256 amountBOutMin)
external nonReentrant returns (uint256 amountBOut) {
require(amountAIn > 0, "MiniSwapAMM: INSUFFICIENT_INPUT_AMOUNT");
amountBOut = getAmountOut(amountAIn, reserveA, reserveB);
require(amountBOut >= amountBOutMin, "MiniSwapAMM: INSUFFICIENT_OUTPUT_AMOUNT");
tokenA.transferFrom(msg.sender, address(this), amountAIn);
tokenB.transfer(msg.sender, amountBOut);
_updateReserves();
emit Swap(msg.sender, address(tokenA), address(tokenB), amountAIn, amountBOut);
}
/**
* @dev 交换代币 B 到代币 A
* @param amountBIn 输入的 tokenB 数量
* @param amountAOutMin 最小输出的 tokenA 数量
* @return amountAOut 实际输出的 tokenA 数量
*/
function swapBForA(uint256 amountBIn, uint256 amountAOutMin)
external nonReentrant returns (uint256 amountAOut) {
require(amountBIn > 0, "MiniSwapAMM: INSUFFICIENT_INPUT_AMOUNT");
amountAOut = getAmountOut(amountBIn, reserveB, reserveA);
require(amountAOut >= amountAOutMin, "MiniSwapAMM: INSUFFICIENT_OUTPUT_AMOUNT");
tokenB.transferFrom(msg.sender, address(this), amountBIn);
tokenA.transfer(msg.sender, amountAOut);
_updateReserves();
emit Swap(msg.sender, address(tokenB), address(tokenA), amountBIn, amountAOut);
}
/**
* @dev 根据输入数量计算输出数量 (基于 x * y = k 公式)
* @param amountIn 输入数量
* @param reserveIn 输入代币储备量
* @param reserveOut 输出代币储备量
* @return amountOut 输出数量
*/
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
public pure returns (uint256 amountOut) {
require(amountIn > 0, "MiniSwapAMM: INSUFFICIENT_INPUT_AMOUNT");
require(reserveIn > 0 && reserveOut > 0, "MiniSwapAMM: INSUFFICIENT_LIQUIDITY");
// 考虑 0.3% 的交易费用
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = (reserveIn * 1000) + amountInWithFee;
amountOut = numerator / denominator;
}
/**
* @dev 获取当前储备量
*/
function getReserves() external view returns (uint256 _reserveA, uint256 _reserveB) {
return (reserveA, reserveB);
}
/**
* @dev 计算最优的代币数量比例
*/
function _calculateOptimalAmounts(
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) internal view returns (uint256 amountA, uint256 amountB) {
if (reserveA == 0 && reserveB == 0) {
// 首次添加流动性
return (amountADesired, amountBDesired);
} else {
uint256 amountBOptimal = (amountADesired * reserveB) / reserveA;
if (amountBOptimal <= amountBDesired) {
require(amountBOptimal >= amountBMin, "MiniSwapAMM: INSUFFICIENT_B_AMOUNT");
return (amountADesired, amountBOptimal);
} else {
uint256 amountAOptimal = (amountBDesired * reserveA) / reserveB;
require(amountAOptimal <= amountADesired && amountAOptimal >= amountAMin,
"MiniSwapAMM: INSUFFICIENT_A_AMOUNT");
return (amountAOptimal, amountBDesired);
}
}
}
/**
* @dev 铸造流动性代币
*/
function _mintLiquidity(uint256 amountA, uint256 amountB) internal returns (uint256 liquidity) {
uint256 totalSupply = totalSupply();
if (totalSupply == 0) {
// 首次添加流动性
liquidity = Math.sqrt(amountA * amountB);
require(liquidity > MINIMUM_LIQUIDITY, "MiniSwapAMM: INSUFFICIENT_LIQUIDITY_MINTED");
// 从流动性中减去最小流动性,永久锁定
liquidity = liquidity - MINIMUM_LIQUIDITY;
} else {
liquidity = Math.min(
(amountA * totalSupply) / reserveA,
(amountB * totalSupply) / reserveB
);
}
require(liquidity > 0, "MiniSwapAMM: INSUFFICIENT_LIQUIDITY_MINTED");
_mint(msg.sender, liquidity);
_updateReserves();
}
/**
* @dev 更新储备量
*/
function _updateReserves() internal {
reserveA = tokenA.balanceOf(address(this));
reserveB = tokenB.balanceOf(address(this));
}
}

34
contracts/TokenA.sol Normal file
View File

@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title TokenA
* @dev 用于 DEX 的第一个 ERC-20 代币
*/
contract TokenA is ERC20, Ownable {
constructor() ERC20("Token A", "TKA") Ownable(msg.sender) {
// 初始铸造 1,000,000 个代币给部署者
_mint(msg.sender, 1000000 * 10**decimals());
}
/**
* @dev 允许所有者铸造更多代币
* @param to 接收代币的地址
* @param amount 铸造的代币数量
*/
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
/**
* @dev 允许任何人铸造代币用于测试(仅限测试环境)
* @param amount 铸造的代币数量
*/
function faucet(uint256 amount) public {
require(amount <= 10000 * 10**decimals(), "TokenA: Maximum 10,000 tokens per faucet call");
_mint(msg.sender, amount);
}
}

34
contracts/TokenB.sol Normal file
View File

@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title TokenB
* @dev 用于 DEX 的第二个 ERC-20 代币
*/
contract TokenB is ERC20, Ownable {
constructor() ERC20("Token B", "TKB") Ownable(msg.sender) {
// 初始铸造 1,000,000 个代币给部署者
_mint(msg.sender, 1000000 * 10**decimals());
}
/**
* @dev 允许所有者铸造更多代币
* @param to 接收代币的地址
* @param amount 铸造的代币数量
*/
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
/**
* @dev 允许任何人铸造代币用于测试(仅限测试环境)
* @param amount 铸造的代币数量
*/
function faucet(uint256 amount) public {
require(amount <= 10000 * 10**decimals(), "TokenB: Maximum 10,000 tokens per faucet call");
_mint(msg.sender, amount);
}
}

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"
]
}

30
hardhat.config.js Normal file
View File

@ -0,0 +1,30 @@
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
networks: {
localhost: {
url: "http://127.0.0.1:8545",
chainId: 31337
},
hardhat: {
chainId: 31337
}
},
paths: {
sources: "./contracts",
tests: "./test",
cache: "./cache",
artifacts: "./artifacts"
}
};

7879
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "mini-swap-dex",
"version": "1.0.0",
"description": "一个简单的去中心化交易所 AMM 模型",
"main": "index.js",
"scripts": {
"test": "hardhat test",
"compile": "hardhat compile",
"deploy": "hardhat run scripts/deploy.js --network localhost",
"node": "hardhat node",
"dev": "cd frontend && npm start",
"build": "cd frontend && npm run build"
},
"keywords": ["defi", "dex", "amm", "ethereum", "solidity", "react"],
"author": "",
"license": "MIT",
"devDependencies": {
"@nomicfoundation/hardhat-toolbox": "^4.0.0",
"hardhat": "^2.19.0"
},
"dependencies": {
"@openzeppelin/contracts": "^5.0.0",
"dotenv": "^16.3.1"
}
}

1
scripts/.gitkeep Normal file
View File

@ -0,0 +1 @@
# 部署脚本目录

76
scripts/deploy.js Normal file
View File

@ -0,0 +1,76 @@
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("使用账户部署合约:", deployer.address);
console.log("账户余额:", (await deployer.provider.getBalance(deployer.address)).toString());
// 部署 TokenA
console.log("\n部署 TokenA...");
const TokenA = await ethers.getContractFactory("TokenA");
const tokenA = await TokenA.deploy();
await tokenA.waitForDeployment();
console.log("TokenA 部署到:", await tokenA.getAddress());
// 部署 TokenB
console.log("\n部署 TokenB...");
const TokenB = await ethers.getContractFactory("TokenB");
const tokenB = await TokenB.deploy();
await tokenB.waitForDeployment();
console.log("TokenB 部署到:", await tokenB.getAddress());
// 部署 MiniSwapAMM
console.log("\n部署 MiniSwapAMM...");
const MiniSwapAMM = await ethers.getContractFactory("MiniSwapAMM");
const miniSwapAMM = await MiniSwapAMM.deploy(
await tokenA.getAddress(),
await tokenB.getAddress()
);
await miniSwapAMM.waitForDeployment();
console.log("MiniSwapAMM 部署到:", await miniSwapAMM.getAddress());
// 给部署者和其他账户铸造一些代币用于测试
console.log("\n铸造测试代币...");
const signers = await ethers.getSigners();
const testAccounts = signers.slice(0, 3); // 获取前 3 个账户
for (let i = 0; i < testAccounts.length; i++) {
const account = testAccounts[i];
// 给每个账户铸造 10,000 个 TokenA 和 TokenB
await tokenA.connect(account).faucet(ethers.parseEther("10000"));
await tokenB.connect(account).faucet(ethers.parseEther("10000"));
console.log(`给账户 ${account.address} 铸造了 10,000 TKA 和 10,000 TKB`);
}
// 保存合约地址到文件
const fs = require("fs");
const contractAddresses = {
TokenA: await tokenA.getAddress(),
TokenB: await tokenB.getAddress(),
MiniSwapAMM: await miniSwapAMM.getAddress(),
deployer: deployer.address
};
fs.writeFileSync(
"contract-addresses.json",
JSON.stringify(contractAddresses, null, 2)
);
console.log("\n部署完成");
console.log("合约地址已保存到 contract-addresses.json");
console.log("\n合约地址:");
console.log("TokenA:", await tokenA.getAddress());
console.log("TokenB:", await tokenB.getAddress());
console.log("MiniSwapAMM:", await miniSwapAMM.getAddress());
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

1
test/.gitkeep Normal file
View File

@ -0,0 +1 @@
# 测试文件目录

283
test/MiniSwapAMM.test.js Normal file
View File

@ -0,0 +1,283 @@
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("MiniSwapAMM", function () {
let TokenA, TokenB, MiniSwapAMM;
let tokenA, tokenB, miniSwapAMM;
let owner, addr1, addr2;
const INITIAL_SUPPLY = ethers.parseEther("1000000");
const LIQUIDITY_AMOUNT_A = ethers.parseEther("1000");
const LIQUIDITY_AMOUNT_B = ethers.parseEther("2000");
beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
// 部署代币合约
TokenA = await ethers.getContractFactory("TokenA");
tokenA = await TokenA.deploy();
await tokenA.waitForDeployment();
TokenB = await ethers.getContractFactory("TokenB");
tokenB = await TokenB.deploy();
await tokenB.waitForDeployment();
// 部署 AMM 合约
MiniSwapAMM = await ethers.getContractFactory("MiniSwapAMM");
miniSwapAMM = await MiniSwapAMM.deploy(
await tokenA.getAddress(),
await tokenB.getAddress()
);
await miniSwapAMM.waitForDeployment();
// 给测试账户铸造代币
await tokenA.connect(addr1).faucet(ethers.parseEther("10000"));
await tokenB.connect(addr1).faucet(ethers.parseEther("10000"));
await tokenA.connect(addr2).faucet(ethers.parseEther("10000"));
await tokenB.connect(addr2).faucet(ethers.parseEther("10000"));
});
describe("部署", function () {
it("应该正确设置代币地址", async function () {
expect(await miniSwapAMM.tokenA()).to.equal(await tokenA.getAddress());
expect(await miniSwapAMM.tokenB()).to.equal(await tokenB.getAddress());
});
it("应该正确设置 LP 代币信息", async function () {
expect(await miniSwapAMM.name()).to.equal("MiniSwap LP Token");
expect(await miniSwapAMM.symbol()).to.equal("MSLP");
});
it("初始储备应该为 0", async function () {
const [reserveA, reserveB] = await miniSwapAMM.getReserves();
expect(reserveA).to.equal(0);
expect(reserveB).to.equal(0);
});
});
describe("添加流动性", function () {
it("应该能够添加初始流动性", async function () {
// 授权 AMM 合约使用代币
await tokenA.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_A);
await tokenB.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_B);
// 添加流动性
const tx = await miniSwapAMM.connect(addr1).addLiquidity(
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B,
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B
);
// 检查事件
await expect(tx).to.emit(miniSwapAMM, "AddLiquidity")
.withArgs(addr1.address, LIQUIDITY_AMOUNT_A, LIQUIDITY_AMOUNT_B, anyValue);
// 检查储备
const [reserveA, reserveB] = await miniSwapAMM.getReserves();
expect(reserveA).to.equal(LIQUIDITY_AMOUNT_A);
expect(reserveB).to.equal(LIQUIDITY_AMOUNT_B);
// 检查 LP 代币余额
const lpBalance = await miniSwapAMM.balanceOf(addr1.address);
expect(lpBalance).to.be.gt(0);
});
it("应该能够添加后续流动性", async function () {
// 首先添加初始流动性
await tokenA.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_A);
await tokenB.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_B);
await miniSwapAMM.connect(addr1).addLiquidity(
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B,
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B
);
// 添加更多流动性
const additionalA = ethers.parseEther("500");
const additionalB = ethers.parseEther("1000");
await tokenA.connect(addr2).approve(await miniSwapAMM.getAddress(), additionalA);
await tokenB.connect(addr2).approve(await miniSwapAMM.getAddress(), additionalB);
await miniSwapAMM.connect(addr2).addLiquidity(
additionalA,
additionalB,
0,
0
);
// 检查储备增加
const [reserveA, reserveB] = await miniSwapAMM.getReserves();
expect(reserveA).to.equal(LIQUIDITY_AMOUNT_A + additionalA);
expect(reserveB).to.equal(LIQUIDITY_AMOUNT_B + additionalB);
});
});
describe("移除流动性", function () {
beforeEach(async function () {
// 添加初始流动性
await tokenA.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_A);
await tokenB.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_B);
await miniSwapAMM.connect(addr1).addLiquidity(
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B,
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B
);
});
it("应该能够移除流动性", async function () {
const lpBalance = await miniSwapAMM.balanceOf(addr1.address);
const halfLiquidity = lpBalance / 2n;
const tx = await miniSwapAMM.connect(addr1).removeLiquidity(
halfLiquidity,
0,
0
);
// 检查事件
await expect(tx).to.emit(miniSwapAMM, "RemoveLiquidity");
// 检查 LP 代币余额减少
const newLpBalance = await miniSwapAMM.balanceOf(addr1.address);
expect(newLpBalance).to.equal(lpBalance - halfLiquidity);
});
it("移除流动性时应该按比例返回代币", async function () {
const lpBalance = await miniSwapAMM.balanceOf(addr1.address);
const balanceABefore = await tokenA.balanceOf(addr1.address);
const balanceBBefore = await tokenB.balanceOf(addr1.address);
await miniSwapAMM.connect(addr1).removeLiquidity(
lpBalance,
0,
0
);
const balanceAAfter = await tokenA.balanceOf(addr1.address);
const balanceBAfter = await tokenB.balanceOf(addr1.address);
// 检查代币返还(接近初始流动性金额,因为有最小流动性锁定)
expect(balanceAAfter - balanceABefore).to.be.closeTo(LIQUIDITY_AMOUNT_A, ethers.parseEther("1"));
expect(balanceBAfter - balanceBBefore).to.be.closeTo(LIQUIDITY_AMOUNT_B, ethers.parseEther("1"));
});
});
describe("代币交换", function () {
beforeEach(async function () {
// 添加流动性
await tokenA.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_A);
await tokenB.connect(addr1).approve(await miniSwapAMM.getAddress(), LIQUIDITY_AMOUNT_B);
await miniSwapAMM.connect(addr1).addLiquidity(
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B,
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B
);
});
it("应该能够用 TokenA 交换 TokenB", async function () {
const swapAmount = ethers.parseEther("100");
// 计算预期输出
const expectedOutput = await miniSwapAMM.getAmountOut(
swapAmount,
LIQUIDITY_AMOUNT_A,
LIQUIDITY_AMOUNT_B
);
await tokenA.connect(addr2).approve(await miniSwapAMM.getAddress(), swapAmount);
const balanceBBefore = await tokenB.balanceOf(addr2.address);
const tx = await miniSwapAMM.connect(addr2).swapAForB(swapAmount, 0);
// 检查事件
await expect(tx).to.emit(miniSwapAMM, "Swap");
const balanceBAfter = await tokenB.balanceOf(addr2.address);
const actualOutput = balanceBAfter - balanceBBefore;
expect(actualOutput).to.equal(expectedOutput);
});
it("应该能够用 TokenB 交换 TokenA", async function () {
const swapAmount = ethers.parseEther("200");
const expectedOutput = await miniSwapAMM.getAmountOut(
swapAmount,
LIQUIDITY_AMOUNT_B,
LIQUIDITY_AMOUNT_A
);
await tokenB.connect(addr2).approve(await miniSwapAMM.getAddress(), swapAmount);
const balanceABefore = await tokenA.balanceOf(addr2.address);
await miniSwapAMM.connect(addr2).swapBForA(swapAmount, 0);
const balanceAAfter = await tokenA.balanceOf(addr2.address);
const actualOutput = balanceAAfter - balanceABefore;
expect(actualOutput).to.equal(expectedOutput);
});
it("交换应该改变储备比例", async function () {
const swapAmount = ethers.parseEther("100");
const [reserveABefore, reserveBBefore] = await miniSwapAMM.getReserves();
await tokenA.connect(addr2).approve(await miniSwapAMM.getAddress(), swapAmount);
await miniSwapAMM.connect(addr2).swapAForB(swapAmount, 0);
const [reserveAAfter, reserveBAfter] = await miniSwapAMM.getReserves();
expect(reserveAAfter).to.be.gt(reserveABefore);
expect(reserveBAfter).to.be.lt(reserveBBefore);
});
it("应该拒绝输出不足的交换", async function () {
const swapAmount = ethers.parseEther("100");
const minOutput = ethers.parseEther("1000"); // 设置过高的最小输出
await tokenA.connect(addr2).approve(await miniSwapAMM.getAddress(), swapAmount);
await expect(
miniSwapAMM.connect(addr2).swapAForB(swapAmount, minOutput)
).to.be.revertedWith("MiniSwapAMM: INSUFFICIENT_OUTPUT_AMOUNT");
});
});
describe("价格计算", function () {
it("getAmountOut 应该正确计算输出数量", async function () {
const amountIn = ethers.parseEther("100");
const reserveIn = ethers.parseEther("1000");
const reserveOut = ethers.parseEther("2000");
const amountOut = await miniSwapAMM.getAmountOut(amountIn, reserveIn, reserveOut);
// 手动计算预期值:(100 * 997 * 2000) / (1000 * 1000 + 100 * 997)
const amountInWithFee = amountIn * 997n;
const numerator = amountInWithFee * reserveOut;
const denominator = reserveIn * 1000n + amountInWithFee;
const expected = numerator / denominator;
expect(amountOut).to.equal(expected);
});
it("应该在没有流动性时拒绝计算", async function () {
const amountIn = ethers.parseEther("100");
await expect(
miniSwapAMM.getAmountOut(amountIn, 0, 1000)
).to.be.revertedWith("MiniSwapAMM: INSUFFICIENT_LIQUIDITY");
});
});
});
// 用于检查任意值的辅助函数
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");