本文将带领你从零构建一个完整的 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
生产检查清单
| 检查项 | 状态 | 说明 |
|---|---|---|
| 合约审计 | ✅ | 主网部署前必须完成 |
| 多签管理 | ✅ | 合约所有权转移至多签钱包 |
| 时间锁 | ✅ | 关键参数变更延迟执行 |
| 前端 HTTPS | ✅ | Vercel 默认提供 |
| RPC 备用 | ✅ | 至少两个 RPC 提供商 |
| 监控告警 | ✅ | Tenderly / Forta |
六、本章小结
一个完整的 Web3 DApp 需要三层协同工作:
- 智能合约层:使用 Solidity + OpenZeppelin 编写安全的 NFT 合约,Hardhat 进行测试和部署
- 前端层:Next.js + Wagmi + RainbowKit 提供类型安全的钱包连接和链交互
- 基础设施层:IPFS/Pinata 存储元数据,Alchemy 提供 RPC 节点
通过本实战项目,你已经掌握了从合约开发到前端集成再到生产部署的完整链路。这是 Web3 全栈开发的基础范式,在此之上可以扩展更复杂的 DeFi、DAO 治理等高级功能。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。