Web3 全栈 DApp 开发实战:从前端到智能合约的完整链路

从零构建一个完整的 Web3 DApp:使用 Next.js + Wagmi + RainbowKit 搭建前端,Hardhat 开发 Solidity 智能合约,部署到 Sepolia 测试网,并发布到 Vercel。

本文将带领你从零构建一个完整的 Web3 应用:一个允许用户铸造和查看 NFT 的 DApp。我们将覆盖智能合约开发、前端交互、钱包连接和部署的完整链路。

一、项目概述与架构

Full Stack DApp 架构:

┌─────────────────────────────────────────────────┐
│  Frontend (Next.js + React + TailwindCSS)       │
│  ├─ wagmi: 区块链交互 Hook                        │
│  ├─ RainbowKit: 钱包连接 UI                       │
│  ├─ viem: 底层 SDK                               │
│  └─ 部署: Vercel                                  │
└────────────────┬────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────┐
│  Smart Contract (Solidity + Hardhat)             │
│  ├─ OpenZeppelin ERC-721 标准实现                │
│  ├─ 铸造功能 + 元数据管理                         │
│  └─ 部署: Sepolia Testnet                        │
└─────────────────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────┐
│  Storage & RPC                                   │
│  ├─ IPFS (Pinata): NFT 元数据和图片存储          │
│  └─ Alchemy: Sepolia RPC 节点                    │
└─────────────────────────────────────────────────┘

二、智能合约开发

项目初始化

mkdir nft-minter-dapp && cd nft-minter-dapp
mkdir contract && cd contract
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init  # 选择 "Create a TypeScript project"

合约代码

// contracts/NFTMinter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract NFTMinter is ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    
    uint256 public mintPrice = 0.001 ether;  // 铸造价格
    uint256 public maxSupply = 1000;          // 最大供应量
    string public baseTokenURI;               // 基础元数据 URI
    
    mapping(uint256 => string) private _tokenNames;
    
    event NFTMinted(address indexed minter, uint256 indexed tokenId, string tokenURI);
    
    constructor(string memory _baseURI) ERC721("DApp NFT Collection", "DAPP") {
        baseTokenURI = _baseURI;
    }
    
    function mint(string memory _tokenURI) external payable returns (uint256) {
        require(msg.value >= mintPrice, "Insufficient payment");
        require(_tokenIds.current() < maxSupply, "Max supply reached");
        
        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();
        
        _safeMint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, _tokenURI);
        
        emit NFTMinted(msg.sender, newTokenId, _tokenURI);
        return newTokenId;
    }
    
    function batchMint(string[] memory _tokenURIs) external payable returns (uint256[] memory) {
        require(_tokenURIs.length > 0, "Empty batch");
        require(msg.value >= mintPrice * _tokenURIs.length, "Insufficient payment");
        require(_tokenIds.current() + _tokenURIs.length <= maxSupply, "Exceeds max supply");
        
        uint256[] memory tokenIds = new uint256[](_tokenURIs.length);
        
        for (uint256 i = 0; i < _tokenURIs.length; i++) {
            _tokenIds.increment();
            uint256 newTokenId = _tokenIds.current();
            _safeMint(msg.sender, newTokenId);
            _setTokenURI(newTokenId, _tokenURIs[i]);
            tokenIds[i] = newTokenId;
            emit NFTMinted(msg.sender, newTokenId, _tokenURIs[i]);
        }
        
        return tokenIds;
    }
    
    function withdraw() external onlyOwner {
        uint256 balance = address(this).balance;
        require(balance > 0, "No funds to withdraw");
        
        (bool success, ) = payable(owner()).call{value: balance}("");
        require(success, "Transfer failed");
    }
    
    function setMintPrice(uint256 _newPrice) external onlyOwner {
        mintPrice = _newPrice;
    }
    
    function totalSupply() external view returns (uint256) {
        return _tokenIds.current();
    }
    
    function tokensOfOwner(address _owner) external view returns (uint256[] memory) {
        uint256 tokenCount = balanceOf(_owner);
        uint256[] memory tokenIds = new uint256[](tokenCount);
        uint256 counter = 0;
        
        for (uint256 i = 1; i <= _tokenIds.current(); i++) {
            if (ownerOf(i) == _owner) {
                tokenIds[counter] = i;
                counter++;
            }
        }
        
        return tokenIds;
    }
}

Hardhat 配置与部署脚本

// hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";

dotenv.config();

const config: HardhatUserConfig = {
    solidity: {
        version: "0.8.19",
        settings: {
            optimizer: {
                enabled: true,
                runs: 200,
            },
        },
    },
    networks: {
        sepolia: {
            url: process.env.ALCHEMY_SEPOLIA_URL || "",
            accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
        },
    },
    etherscan: {
        apiKey: process.env.ETHERSCAN_API_KEY,
    },
    gasReporter: {
        enabled: true,
        currency: "USD",
    },
};

export default config;
// scripts/deploy.ts
import { ethers } from "hardhat";

async function main() {
    const baseURI = "ipfs://QmYourBaseURI/";  // 实际部署时替换
    
    const NFTMinter = await ethers.getContractFactory("NFTMinter");
    const nftMinter = await NFTMinter.deploy(baseURI);
    
    await nftMinter.waitForDeployment();
    
    const address = await nftMinter.getAddress();
    console.log(`NFTMinter deployed to: ${address}`);
    
    // 验证合约(可选)
    console.log(`Verify with: npx hardhat verify --network sepolia ${address} "${baseURI}"`);
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});
# 部署到 Sepolia
npx hardhat run scripts/deploy.ts --network sepolia

# 验证合约源码
npx hardhat verify --network sepolia DEPLOYED_ADDRESS "ipfs://QmYourBaseURI/"

测试代码

// test/NFTMinter.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("NFTMinter", function () {
    let nftMinter: any;
    let owner: any;
    let addr1: any;
    
    beforeEach(async function () {
        [owner, addr1] = await ethers.getSigners();
        const NFTMinter = await ethers.getContractFactory("NFTMinter");
        nftMinter = await NFTMinter.deploy("ipfs://base/");
    });
    
    it("Should mint an NFT with correct payment", async function () {
        const mintPrice = await nftMinter.mintPrice();
        
        await expect(
            nftMinter.connect(addr1).mint("ipfs://token1", { value: mintPrice })
        )
            .to.emit(nftMinter, "NFTMinted")
            .withArgs(addr1.address, 1, "ipfs://token1");
        
        expect(await nftMinter.ownerOf(1)).to.equal(addr1.address);
        expect(await nftMinter.tokenURI(1)).to.equal("ipfs://token1");
    });
    
    it("Should reject insufficient payment", async function () {
        await expect(
            nftMinter.connect(addr1).mint("ipfs://token1", { value: 0 })
        ).to.be.revertedWith("Insufficient payment");
    });
    
    it("Should allow owner to withdraw", async function () {
        const mintPrice = await nftMinter.mintPrice();
        await nftMinter.connect(addr1).mint("ipfs://token1", { value: mintPrice });
        
        const initialBalance = await ethers.provider.getBalance(owner.address);
        await nftMinter.connect(owner).withdraw();
        const finalBalance = await ethers.provider.getBalance(owner.address);
        
        expect(finalBalance).to.be.gt(initialBalance);
    });
});

三、前端开发(Next.js + Wagmi + RainbowKit)

项目初始化

cd ..
mkdir frontend && cd frontend
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir
npm install wagmi viem @rainbow-me/rainbowkit @tanstack/react-query

链配置与 Provider

// src/app/wagmi.ts
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { sepolia } from "wagmi/chains";

export const config = getDefaultConfig({
    appName: "NFT Minter DApp",
    projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
    chains: [sepolia],
    ssr: true,
});

// 合约配置
export const CONTRACT_ADDRESS = "0xYourDeployedContractAddress" as const;

export const CONTRACT_ABI = [
    {
        inputs: [{ name: "_tokenURI", type: "string" }],
        name: "mint",
        outputs: [{ name: "", type: "uint256" }],
        stateMutability: "payable",
        type: "function",
    },
    {
        inputs: [{ name: "owner", type: "address" }],
        name: "tokensOfOwner",
        outputs: [{ name: "", type: "uint256[]" }],
        stateMutability: "view",
        type: "function",
    },
    {
        inputs: [],
        name: "mintPrice",
        outputs: [{ name: "", type: "uint256" }],
        stateMutability: "view",
        type: "function",
    },
    {
        inputs: [],
        name: "totalSupply",
        outputs: [{ name: "", type: "uint256" }],
        stateMutability: "view",
        type: "function",
    },
    {
        inputs: [{ name: "tokenId", type: "uint256" }],
        name: "tokenURI",
        outputs: [{ name: "", type: "string" }],
        stateMutability: "view",
        type: "function",
    },
] as const;
// src/app/providers.tsx
"use client";

import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { config } from "./wagmi";
import "@rainbow-me/rainbowkit/styles.css";

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
    return (
        <WagmiProvider config={config}>
            <QueryClientProvider client={queryClient}>
                <RainbowKitProvider>{children}</RainbowKitProvider>
            </QueryClientProvider>
        </WagmiProvider>
    );
}
// src/app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="zh-CN">
            <body>
                <Providers>{children}</Providers>
            </body>
        </html>
    );
}

铸造页面组件

// src/app/components/NFTMinter.tsx
"use client";

import { useState } from "react";
import { useAccount, useWriteContract, useReadContract, useWaitForTransactionReceipt } from "wagmi";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { parseEther } from "viem";
import { CONTRACT_ADDRESS, CONTRACT_ABI } from "../wagmi";

export function NFTMinter() {
    const { isConnected } = useAccount();
    const [tokenURI, setTokenURI] = useState("");
    
    const { data: mintPrice } = useReadContract({
        address: CONTRACT_ADDRESS,
        abi: CONTRACT_ABI,
        functionName: "mintPrice",
    });
    
    const { data: totalSupply } = useReadContract({
        address: CONTRACT_ADDRESS,
        abi: CONTRACT_ABI,
        functionName: "totalSupply",
    });
    
    const { writeContract, data: hash } = useWriteContract();
    
    const { isLoading, isSuccess } = useWaitForTransactionReceipt({ hash });
    
    const handleMint = () => {
        if (!tokenURI) return;
        
        writeContract({
            address: CONTRACT_ADDRESS,
            abi: CONTRACT_ABI,
            functionName: "mint",
            args: [tokenURI],
            value: mintPrice || parseEther("0.001"),
        });
    };
    
    return (
        <div className="max-w-2xl mx-auto p-6">
            <h1 className="text-3xl font-bold mb-8 text-center">NFT Minter</h1>
            
            <div className="flex justify-center mb-8">
                <ConnectButton />
            </div>
            
            {isConnected && (
                <div className="space-y-6">
                    <div className="bg-gray-50 rounded-lg p-4">
                        <p className="text-sm text-gray-600">
                            已铸造: {totalSupply?.toString() || "0"} / 1000
                        </p>
                        <p className="text-sm text-gray-600">
                            铸造价格: {mintPrice ? `${Number(mintPrice) / 1e18} ETH` : "加载中..."}
                        </p>
                    </div>
                    
                    <div>
                        <label className="block text-sm font-medium mb-2">
                            Token URI (IPFS 链接)
                        </label>
                        <input
                            type="text"
                            value={tokenURI}
                            onChange={(e) => setTokenURI(e.target.value)}
                            placeholder="ipfs://Qm..."
                            className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
                        />
                    </div>
                    
                    <button
                        onClick={handleMint}
                        disabled={isLoading || !tokenURI}
                        className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium
                                   disabled:bg-gray-400 hover:bg-blue-700 transition"
                    >
                        {isLoading ? "铸造中..." : isSuccess ? "铸造成功!" : "铸造 NFT"}
                    </button>
                    
                    {hash && (
                        <p className="text-sm text-center">
                            交易哈希:{" "}
                            <a
                                href={`https://sepolia.etherscan.io/tx/${hash}`}
                                target="_blank"
                                rel="noopener noreferrer"
                                className="text-blue-600 hover:underline"
                            >
                                {hash.slice(0, 10)}...{hash.slice(-8)}
                            </a>
                        </p>
                    )}
                </div>
            )}
        </div>
    );
}
// src/app/page.tsx
import { NFTMinter } from "./components/NFTMinter";

export default function Home() {
    return (
        <main className="min-h-screen bg-white">
            <NFTMinter />
        </main>
    );
}

四、IPFS 元数据管理

上传脚本

// scripts/upload-to-ipfs.ts
import pinataSDK from "@pinata/sdk";
import fs from "fs";
import path from "path";

const pinata = new pinataSDK({
    pinataApiKey: process.env.PINATA_API_KEY!,
    pinataSecretApiKey: process.env.PINATA_SECRET!,
});

async function uploadNFTMetadata(imagePath: string, name: string, description: string) {
    // 1. 上传图片
    const imageStream = fs.createReadStream(imagePath);
    const imageResult = await pinata.pinFileToIPFS(imageStream, {
        pinataMetadata: { name: `${name}-image` },
    });
    const imageURI = `ipfs://${imageResult.IpfsHash}`;
    
    // 2. 构建并上传元数据
    const metadata = {
        name,
        description,
        image: imageURI,
        attributes: [
            { trait_type: "Created By", value: "DApp Minter" },
            { trait_type: "Rarity", value: "Common" },
        ],
    };
    
    const metadataResult = await pinata.pinJSONToIPFS(metadata, {
        pinataMetadata: { name: `${name}-metadata` },
    });
    
    return `ipfs://${metadataResult.IpfsHash}`;
}

// 使用示例
uploadNFTMetadata("./assets/nft1.png", "My First NFT", "A cool NFT minted via DApp")
    .then(uri => console.log("Metadata URI:", uri));

五、部署与运维

Vercel 部署配置

// vercel.json
{
    "framework": "nextjs",
    "env": {
        "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID": "@wc-project-id"
    }
}
# 环境变量
# .env.local(不提交到 Git)
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id

# 部署
vercel --prod

生产检查清单

检查项状态说明
合约审计主网部署前必须完成
多签管理合约所有权转移至多签钱包
时间锁关键参数变更延迟执行
前端 HTTPSVercel 默认提供
RPC 备用至少两个 RPC 提供商
监控告警Tenderly / Forta

六、本章小结

一个完整的 Web3 DApp 需要三层协同工作:

  1. 智能合约层:使用 Solidity + OpenZeppelin 编写安全的 NFT 合约,Hardhat 进行测试和部署
  2. 前端层:Next.js + Wagmi + RainbowKit 提供类型安全的钱包连接和链交互
  3. 基础设施层:IPFS/Pinata 存储元数据,Alchemy 提供 RPC 节点

通过本实战项目,你已经掌握了从合约开发到前端集成再到生产部署的完整链路。这是 Web3 全栈开发的基础范式,在此之上可以扩展更复杂的 DeFi、DAO 治理等高级功能。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「区块链 Web3」更多文章

  1. 企业级区块链:Hyperledger Fabric 架构与链码开发
  2. 区块链安全:合约审计、攻击模式与防御体系
  3. 跨链技术与桥接:原子互换、跨链桥与 IBC 协议