DeFi 开发

DeFi 入门指南

从 Solidity 合约到 dApp 前端的完整开发教程

什么是 DeFi

DeFi(Decentralized Finance,去中心化金融)是构建在区块链上的金融协议生态。与传统金融不同,DeFi 不依赖银行、券商等中介机构,而是通过智能合约自动执行金融操作——交易、借贷、质押等。

在本教程中,我们将从零构建一个完整的 DeFi 小型项目:

SimpleToken (ERC20)

一个标准的 ERC20 代币合约,包含 name、symbol、decimals、totalSupply、transfer、approve、transferFrom 等完整功能。

SimpleDEX (AMM)

一个基于恒定乘积公式 x·y=k 的简单去中心化交易所,支持添加流动性和代币交换。

dApp 前端

使用 Next.js + ethers.js 构建的前端,连接 MetaMask 钱包,调用合约执行交易。

开发环境搭建

Foundry 是一个用 Rust 编写的高性能 Solidity 开发工具链。相比 Hardhat,Foundry 编译更快、测试用 Solidity 编写(而不是 JavaScript),且内置了丰富的链上交互工具。

安装 Foundry

curl -L https://foundry.paradigm.xyz | bash
foundryup

初始化项目

forge init my-defi-project
cd my-defi-project

# 项目结构 / Project structure:
# ├── src/          ← 合约代码 / Contract code
# ├── test/         ← 测试文件 / Test files
# ├── script/       ← 部署脚本 / Deploy scripts
# └── foundry.toml  ← 配置文件 / Config

forge

Solidity 编译器和测试框架,类似 Hardhat 但更快。用 Solidity 写测试而不是 JavaScript。

cast

命令行工具,可以直接与部署的合约交互、查询链上数据、编码/解码 ABI。

anvil

本地 Ethereum 节点,用于开发和测试。类似 Ganache 但原生集成。

chisel

交互式 Solidity REPL,可以快速实验 Solidity 代码片段。

编写 ERC20 代币合约

ERC20 是以太坊上最基础的代币标准。它定义了一组标准接口,让所有 ERC20 代币都可以被钱包、DEX 等通用工具识别和操作。下面是一个完整的实现:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleToken {
    string public name;
    string public symbol;
    uint8  public decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
        name = _name;
        symbol = _symbol;
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        require(balanceOf[from] >= amount, "Insufficient balance");
        require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

approve + transferFrom 授权机制

ERC20 的核心设计模式是两步授权:用户先 approve 授权某个合约(如 DEX)可以使用自己的代币,然后 DEX 调用 transferFrom 把代币从用户账户转到自己账户。这种模式避免了直接向合约转账可能导致的安全问题。

编写简单 DEX 合约

我们的 SimpleDEX 使用恒定乘积公式 x·y=k 实现自动做市。这是 Uniswap V2 的核心算法——两种代币的储备量之积始终保持为常数 k。当有人买入 Token B 时,池中 Token A 增加、Token B 减少,价格自动调整。

x · y = k
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./SimpleToken.sol";

contract SimpleDEX {
    SimpleToken public tokenA;
    SimpleToken public tokenB;

    uint256 public reserveA;
    uint256 public reserveB;

    event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB);
    event Swapped(address indexed user, string direction, uint256 amountIn, uint256 amountOut);

    constructor(address _tokenA, address _tokenB) {
        tokenA = SimpleToken(_tokenA);
        tokenB = SimpleToken(_tokenB);
    }

    function addLiquidity(uint256 amountA, uint256 amountB) external {
        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);
        reserveA += amountA;
        reserveB += amountB;
        emit LiquidityAdded(msg.sender, amountA, amountB);
    }

    function swapAForB(uint256 amountA) external {
        require(amountA > 0, "Amount must be > 0");
        tokenA.transferFrom(msg.sender, address(this), amountA);

        // 0.3% fee
        uint256 fee = amountA * 3 / 1000;
        uint256 amountInAfterFee = amountA - fee;

        // Constant product: dy = y * dx / (x + dx)
        uint256 amountOut = (reserveB * amountInAfterFee) / (reserveA + amountInAfterFee);

        reserveA += amountA;
        reserveB -= amountOut;

        tokenB.transfer(msg.sender, amountOut);
        emit Swapped(msg.sender, "A->B", amountA, amountOut);
    }

    function swapBForA(uint256 amountB) external {
        require(amountB > 0, "Amount must be > 0");
        tokenB.transferFrom(msg.sender, address(this), amountB);

        uint256 fee = amountB * 3 / 1000;
        uint256 amountInAfterFee = amountB - fee;

        uint256 amountOut = (reserveA * amountInAfterFee) / (reserveB + amountInAfterFee);

        reserveB += amountB;
        reserveA -= amountOut;

        tokenA.transfer(msg.sender, amountOut);
        emit Swapped(msg.sender, "B->A", amountB, amountOut);
    }

    function getPrice() external view returns (uint256 priceAinB, uint256 priceBinA) {
        require(reserveA > 0 && reserveB > 0, "No liquidity");
        priceAinB = (reserveB * 1e18) / reserveA;
        priceBinA = (reserveA * 1e18) / reserveB;
    }
}

Foundry 测试

Foundry 的测试用 Solidity 编写,使用 forge-std 库提供的 Test 基类。每个测试函数以 test 开头,使用 assertEq、assertTrue 等断言。vm.prank() 可以模拟不同地址调用。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SimpleToken.sol";
import "../src/SimpleDEX.sol";

contract SimpleDEXTest is Test {
    SimpleToken tokenA;
    SimpleToken tokenB;
    SimpleDEX   dex;
    address     alice = address(0x1);

    function setUp() public {
        tokenA = new SimpleToken("Token A", "TKA", 1_000_000);
        tokenB = new SimpleToken("Token B", "TKB", 1_000_000);
        dex = new SimpleDEX(address(tokenA), address(tokenB));

        // Transfer tokens to Alice for testing
        tokenA.transfer(alice, 100_000 * 1e18);
        tokenB.transfer(alice, 100_000 * 1e18);
    }

    function testAddLiquidity() public {
        vm.startPrank(alice);
        tokenA.approve(address(dex), 10_000 * 1e18);
        tokenB.approve(address(dex), 10_000 * 1e18);
        dex.addLiquidity(10_000 * 1e18, 10_000 * 1e18);
        vm.stopPrank();

        assertEq(dex.reserveA(), 10_000 * 1e18);
        assertEq(dex.reserveB(), 10_000 * 1e18);
    }

    function testSwapAForB() public {
        // Setup liquidity
        vm.startPrank(alice);
        tokenA.approve(address(dex), 10_000 * 1e18);
        tokenB.approve(address(dex), 10_000 * 1e18);
        dex.addLiquidity(10_000 * 1e18, 10_000 * 1e18);

        // Perform swap
        uint256 swapAmount = 1_000 * 1e18;
        tokenA.approve(address(dex), swapAmount);
        uint256 balanceBefore = tokenB.balanceOf(alice);

        dex.swapAForB(swapAmount);

        uint256 balanceAfter = tokenB.balanceOf(alice);
        vm.stopPrank();

        // Alice should have received some Token B
        assertTrue(balanceAfter > balanceBefore);
        // Reserve A should have increased
        assertTrue(dex.reserveA() > 10_000 * 1e18);
        // Reserve B should have decreased
        assertTrue(dex.reserveB() < 10_000 * 1e18);
    }
}

运行测试

# 运行所有测试 / Run all tests
forge test

# 详细输出 / Verbose output
forge test -vvvv

# 运行特定测试 / Run specific test
forge test --match-test testSwapAForB -vvvv

部署到测试网

Foundry 使用 Solidity 脚本进行部署。下面是部署到 Sepolia 测试网的完整脚本:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import "../src/SimpleToken.sol";
import "../src/SimpleDEX.sol";

contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployerPrivateKey);

        // Deploy tokens
        SimpleToken tokenA = new SimpleToken("Alpha Token", "ALPHA", 1_000_000);
        SimpleToken tokenB = new SimpleToken("Beta Token", "BETA", 1_000_000);

        // Deploy DEX
        SimpleDEX dex = new SimpleDEX(address(tokenA), address(tokenB));

        // Add initial liquidity
        tokenA.approve(address(dex), 50_000 * 1e18);
        tokenB.approve(address(dex), 50_000 * 1e18);
        dex.addLiquidity(50_000 * 1e18, 50_000 * 1e18);

        vm.stopBroadcast();

        console.log("Token A:", address(tokenA));
        console.log("Token B:", address(tokenB));
        console.log("DEX:", address(dex));
    }
}

部署命令

# 配置环境变量 / Set env vars
export PRIVATE_KEY=<your-private-key>
export SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/<api-key>

# 部署到 Sepolia / Deploy to Sepolia
forge script script/Deploy.s.sol:DeployScript \
    --rpc-url $SEPOLIA_RPC_URL \
    --broadcast \
    --verify

# 导出 ABI / Export ABI
forge inspect SimpleToken abi > abi/SimpleToken.json
forge inspect SimpleDEX abi > abi/SimpleDEX.json

ABI 与合约交互

ABI(Application Binary Interface)是合约的接口描述,定义了合约有哪些函数、每个函数接受什么参数、返回什么类型。前端通过 ABI + 合约地址来调用链上合约。每个函数的前 4 字节 keccak256 哈希就是函数选择器。

ABI 浏览器

ABI JSON 结构

{
  "type": "function",
  "name": "transfer",
  "stateMutability": "nonpayable",
  "inputs": [
    {
      "name": "to",
      "type": "address"
    },
    {
      "name": "amount",
      "type": "uint256"
    }
  ],
  "outputs": [
    {
      "name": "",
      "type": "bool"
    }
  ]
}

函数选择器

0xa9059cbb...

Foundry cast 命令

cast call <CONTRACT> "transfer(address,uint256)" <to> <amount>

参数说明

名称类型
toaddress
amountuint256

cast 命令行交互

# 查询余额 / Query balance
cast call <TOKEN_ADDRESS> "balanceOf(address)" <WALLET_ADDRESS> \
    --rpc-url $SEPOLIA_RPC_URL

# 解码返回值 / Decode result
cast --to-dec <hex-result>

# 发送交易 / Send transaction
cast send <TOKEN_ADDRESS> "approve(address,uint256)" \
    <DEX_ADDRESS> 1000000000000000000000 \
    --private-key $PRIVATE_KEY \
    --rpc-url $SEPOLIA_RPC_URL

# 查看事件日志 / View event logs
cast logs --from-block latest "Transfer(address,address,uint256)" \
    --address <TOKEN_ADDRESS> \
    --rpc-url $SEPOLIA_RPC_URL

前端项目搭建

现在我们用 Next.js + ethers.js v6 搭建前端。ethers.js 是最流行的以太坊 JavaScript 库,用于连接钱包、实例化合约、发送交易。

# 创建 Next.js 项目 / Create Next.js project
npx create-next-app@latest my-defi-frontend
cd my-defi-frontend

# 安装 ethers.js v6 / Install ethers.js v6
npm install ethers

项目结构

my-defi-frontend/
├── app/
│   ├── page.tsx           # 主页面
│   └── layout.tsx
├── lib/
│   ├── contracts.ts       # 合约地址和 ABI
│   └── wallet.ts          # 钱包连接逻辑
├── abi/
│   ├── SimpleToken.json   # 从 Foundry 导出
│   └── SimpleDEX.json
└── package.json

合约配置

// lib/contracts.ts
import SimpleTokenABI from "../abi/SimpleToken.json";
import SimpleDEXABI from "../abi/SimpleDEX.json";

export const CONTRACTS = {
  tokenA: {
    address: "0x...",  // 部署后填入
    abi: SimpleTokenABI,
  },
  tokenB: {
    address: "0x...",
    abi: SimpleTokenABI,
  },
  dex: {
    address: "0x...",
    abi: SimpleDEXABI,
  },
} as const;

export const SEPOLIA_CHAIN_ID = 11155111;

连接钱包

ethers.js v6 中,BrowserProvider 替代了旧版的 Web3Provider。通过 window.ethereum(MetaMask 注入的对象)创建 provider,然后获取 signer 来签名交易。

// lib/wallet.ts
import { ethers } from "ethers";
import { SEPOLIA_CHAIN_ID } from "./contracts";

export async function connectWallet() {
  if (!window.ethereum) {
    throw new Error("MetaMask not installed");
  }

  // Request account access
  await window.ethereum.request({
    method: "eth_requestAccounts",
  });

  const provider = new ethers.BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();
  const address = await signer.getAddress();
  const network = await provider.getNetwork();

  // Switch to Sepolia if needed
  if (Number(network.chainId) !== SEPOLIA_CHAIN_ID) {
    await window.ethereum.request({
      method: "wallet_switchEthereumChain",
      params: [{ chainId: ethers.toBeHex(SEPOLIA_CHAIN_ID) }],
    });
  }

  return { provider, signer, address };
}

export function getContract(
  address: string,
  abi: ethers.InterfaceAbi,
  signer: ethers.Signer
) {
  return new ethers.Contract(address, abi, signer);
}

在 React 组件中使用:

"use client";
import { useState } from "react";
import { connectWallet } from "@/lib/wallet";

export default function WalletButton() {
  const [address, setAddress] = useState<string | null>(null);

  async function handleConnect() {
    try {
      const { address } = await connectWallet();
      setAddress(address);
    } catch (err) {
      console.error("Failed to connect:", err);
    }
  }

  return (
    <button onClick={handleConnect}>
      {address
        ? `${address.slice(0, 6)}...${address.slice(-4)}`
        : "Connect Wallet"}
    </button>
  );
}

代币交换界面

代币交换需要两步操作:先 approve 授权 DEX 使用你的代币,然后调用 DEX 的 swap 函数。下面是完整的交互流程和模拟器。

Token 交换模拟器

准备就绪
Step 1: Approve
Step 2: Swap

流动性池状态

Token A Reserve10000.00
Token B Reserve10000.00
k (x * y)100000000
价格 A→B1.000000
价格 B→A1.000000

用户余额

Token A5000.00
Token B0.0000
Allowance0

前端 Swap 实现代码

// Swap component core logic
import { ethers } from "ethers";
import { CONTRACTS } from "@/lib/contracts";
import { getContract } from "@/lib/wallet";

async function handleSwap(signer: ethers.Signer, amount: string) {
  const tokenA = getContract(
    CONTRACTS.tokenA.address,
    CONTRACTS.tokenA.abi,
    signer
  );
  const dex = getContract(
    CONTRACTS.dex.address,
    CONTRACTS.dex.abi,
    signer
  );

  const amountWei = ethers.parseUnits(amount, 18);

  // Step 1: Approve
  const approveTx = await tokenA.approve(
    CONTRACTS.dex.address,
    amountWei
  );
  await approveTx.wait();
  console.log("Approved!");

  // Step 2: Swap
  const swapTx = await dex.swapAForB(amountWei);
  const receipt = await swapTx.wait();
  console.log("Swap done!", receipt.hash);

  // Read updated balance
  const balance = await tokenA.balanceOf(
    await signer.getAddress()
  );
  console.log(
    "New balance:",
    ethers.formatUnits(balance, 18)
  );
}

Solidity ↔ ethers.js 对照

查询某地址的代币余额

Solidity
// Solidity — ERC20
function balanceOf(address account)
    external view returns (uint256)
{
    return _balances[account];
}
ethers.js v6
// ethers.js v6
const balance = await token.balanceOf(
  userAddress
);
console.log(
  "Balance:",
  ethers.formatUnits(balance, 18)
);

端到端完整流程

下面是我们构建的完整 dApp 架构图,展示了从用户交互到链上执行的完整数据流:

端到端流程总结

  • 1. 用 Foundry 编写、测试、部署 SimpleToken 和 SimpleDEX 合约到 Sepolia
  • 2. 从 Foundry 导出合约 ABI JSON 文件
  • 3. Next.js 前端使用 ethers.js v6 连接 MetaMask 钱包
  • 4. 通过 ABI + 合约地址创建合约实例
  • 5. 用户点击 Swap → 前端构造 approve tx → MetaMask 签名 → 广播到链上
  • 6. approve 确认后 → 构造 swap tx → 签名 → 广播 → 合约执行 → 返回结果
  • 7. 前端监听事件、更新 UI 显示最新余额

总结与进阶

恭喜!你已经完成了一个完整的 DeFi 入门项目。从 Solidity 合约编写到 Foundry 测试部署,再到 ethers.js 前端集成,你已经掌握了 DeFi 开发的核心流程。

进阶方向

OpenZeppelin

使用 OpenZeppelin 标准库重写合约,学习 ERC721 (NFT)、ERC1155、Ownable、AccessControl 等模式。

多跳路由

实现多代币池和路由合约,支持 A→B→C 的多跳交换路径。

预言机集成

集成 Chainlink Price Feed 实现价格预言机,或实现 TWAP 时间加权平均价格。

前端进阶

添加交易历史、实时事件监听 (contract.on)、待处理交易状态管理。

💡 提示:本教程的 SimpleDEX 是极简实现,用于学习核心概念。生产级 DEX 还需要考虑重入攻击防护、滑点保护、多池路由、LP 代币、时间锁等安全和功能特性。可以参考本站的 Uniswap 深入解析了解更多。