Men6d656e/08_nft_with_frontend_by_myself
GitHub: Men6d656e/08_nft_with_frontend_by_myself
一个零 OpenZeppelin 依赖、从零实现 ERC-721 标准的全栈 NFT 去中心化应用,涵盖自研 Solidity 合约、Next.js 前端及 IPFS 集成。
Stars: 0 | Forks: 0
# ManualNFT dApp
一个生产级、单仓库(**monorepo**)去中心化应用 (dApp),包含一个从零开始构建、带有完整 NatSpec 文档的自定义 ERC-721 NFT 合约以及 Next.js 前端。**零 OpenZeppelin 依赖** —— 每一个合约原语都是基于第一性原理构建的。
图片会在铸造 (mint) 时上传至 IPFS —— 你可以使用 **IPFS Desktop**(本地,用于 Anvil 开发)或 **Pinata**(远程,用于 Sepolia/生产环境),通过环境变量进行配置。
## 🏗️ 架构
```
08_nft_with_frontend_by_freebuff/
├── contracts/ # Foundry workspace (Solidity)
│ ├── src/
│ │ └── ManualNFT.sol # Custom ERC-721 (no OZ)
│ ├── script/
│ │ └── DeployManualNFT.s.sol # Interactive deployment
│ ├── test/
│ │ └── ManualNFT.t.sol # 34 fuzz + unit tests
│ ├── broadcast/ # Deployment receipts (auto-generated)
│ ├── foundry.toml
│ └── remappings.txt
├── frontend/ # Next.js 16 App Router + TypeScript
│ ├── src/
│ │ ├── app/
│ │ │ ├── layout.tsx # Root layout with Web3Providers
│ │ │ ├── page.tsx # Dashboard (create, mint, inventory)
│ │ │ └── globals.css # TailwindCSS v4 styles
│ │ ├── components/
│ │ │ └── Providers.tsx # Wagmi + RainbowKit + React Query
│ │ ├── hooks/
│ │ │ └── useInventory.ts # Token inventory fetcher
│ │ ├── lib/
│ │ │ ├── wagmi.ts # Wagmi/RainbowKit config
│ │ │ └── ipfs.ts # IPFS upload + resolution (local & Pinata)
│ │ ├── generated.ts # Type-safe hooks (auto-generated)
│ │ └── __tests__/ # Frontend tests
│ ├── wagmi.config.ts # @wagmi/cli → foundry plugin
│ └── vitest.config.ts
├── scripts/
│ └── extract-address.sh # Auto-extract contract address post-deploy
├── Makefile # Single-command orchestration
└── README.md
```
## 📸 截图
| 连接钱包 | 仪表盘与铸造 | 代币库存 |
|:--------------:|:----------------:|:---------------:|
|  |  |  |
## 🛠️ 技术栈
| 层级 | 技术 |
| --------------- | ----------------------------------------------------- |
| **链 (Chain)** | Anvil (本地) / Sepolia (测试网) |
| **合约 (Contracts)** | Solidity ^0.8.20, Foundry (forge, cast, anvil) |
| **前端 (Frontend)** | Next.js 16 (App Router), TypeScript, TailwindCSS v4 |
| **Web3** | Wagmi v2 + RainbowKit + Viem v2 |
| **IPFS** | IPFS Desktop (本地) / Pinata (生产环境) |
| **类型生成** | @wagmi/cli Foundry 插件 → React hooks |
| **测试 (Testing)** | Forge (合约), Vitest + RTL (前端) |
## 🚀 快速开始
### 前置条件
```
# 安装 Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Node.js >= 18
node --version
```
### 1. 安装所有内容
```
make install-all
```
只需一条命令即可安装 Foundry 依赖 (`forge-std`) 以及所有 npm 包。
### 2. 配置环境
```
cp frontend/.env.example frontend/.env.local
cp .env.example .env
```
**必需变量:**
| 变量 | 获取途径 |
|---|---|
| `NEXT_PUBLIC_WC_PROJECT_ID` | [WalletConnect Cloud](https://cloud.walletconnect.com) |
| `NEXT_PUBLIC_SEPOLIA_RPC` | [Alchemy](https://alchemy.com) 或 [Infura](https://infura.io) |
| `SEPOLIA_RPC` | 相同的 RPC URL,但在根目录的 `.env` 中为 Foundry 配置 |
### 3. 启动 Anvil 节点
```
anvil
# 保持此终端打开 — Anvil 在 :8545 上运行本地 Ethereum 节点
```
### 4. 启动 IPFS Desktop
确保 **IPFS Desktop** 正在运行。它将暴露:
- **API**: `http://127.0.0.1:5001`(供应用上传使用)
- **网关**: `http://127.0.0.1:8080`(供应用显示图片使用)
无需额外配置 —— 应用默认使用这些 endpoint。
### 5. 部署合约(本地)
```
make deploy-anvil
```
系统会提示你粘贴 **默认的 Anvil 私钥**:
```
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
```
这一条命令将:
1. ✅ 构建合约
2. ✅ 通过 `forge script --broadcast` 部署 ManualNFT
3. ✅ 从广播日志中自动提取已部署的合约地址
4. ✅ 将 `NEXT_PUBLIC_CONTRACT_ADDRESS` 写入 `frontend/.env.local`
5. ✅ 重新生成 Wagmi 类型安全的 hooks
### 6. 启动前端
```
make frontend-dev
```
打开 [http://localhost:3000](http://localhost:3000),连接你的钱包(在 RainbowKit 中设置 Anvil 链),然后开始铸造!
## 🖼️ IPFS 集成
### 工作原理
当你点击 **Mint Token** 时,流程如下:
```
1. User uploads image
↓
2. App uploads image to IPFS (via local API or Pinata)
↓ ← NEXT_PUBLIC_IPFS_PROVIDER selects which
3. IPFS returns CID ← ipfs://QmX...
↓
4. Metadata JSON built with image: "ipfs://"
↓
5. Metadata base64-encoded → stored on-chain as tokenURI
↓
6. Wallet confirms transaction → Token minted!
```
元数据 JSON(名称、描述、ipfs:// 图片 URL)作为 base64 编码的数据 URI 存储 **在链上** —— 这使得元数据不可变且成本低廉。只有图片文件会被上传至 IPFS。
### 提供商选择
| 变量 | Anvil (本地) | Sepolia (生产环境) |
|---|---|---|
| `NEXT_PUBLIC_IPFS_PROVIDER` | `local` | `pinata` |
| IPFS 服务 | IPFS Desktop(在你的机器上运行) | Pinata 云服务 |
| API Endpoint | `http://127.0.0.1:5001` | `https://api.pinata.cloud` |
| 网关 | `http://127.0.0.1:8080` | `https://gateway.pinata.cloud` |
在 `frontend/.env.local` 中设置环境变量以在提供商之间切换。
### 本地 IPFS Desktop(用于 Anvil)
1. 下载并安装 [IPFS Desktop](https://docs.ipfs.tech/install/ipfs-desktop/)
2. 启动应用程序 —— 它会自动启动 API 和网关
3. 验证它是否正在运行:
curl http://127.0.0.1:5001/api/v0/version
# {"Version":"0.29.0","Commit":"...","Repo":"17","System":"amd64/linux","Golang":"go1.22.5"}
4. 默认端口:`5001` (API) 和 `8080` (网关) —— 可通过环境变量配置
无需更改环境变量 —— 应用默认使用带有标准端口的 `local` 提供商。
### Pinata(用于 Sepolia)
1. 在 [Pinata](https://app.pinata.cloud) 创建一个免费账户
2. 进入 **API Keys** → **New Key** → 启用 `pinFileToIPFS`
3. 复制 **JWT** token
4. 在 `frontend/.env.local` 中设置:
NEXT_PUBLIC_IPFS_PROVIDER=pinata
NEXT_PUBLIC_PINATA_JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
## 🔄 完整开发工作流
### 单命令开发循环
```
make dev-env
```
这会构建合约、生成 wagmi 类型并启动前端开发服务器。
### 部署到 Sepolia
```
make deploy-sepolia
```
系统会提示你输入 Sepolia 部署者私钥。合约地址会被自动提取并为前端配置好。
## 🧪 测试
### 合约测试(34 个测试)
```
make contracts-test
```
测试套件涵盖:
- **Mint**: 基本铸造、供应量跟踪、事件触发
- **Transfer**: 标准 + 安全转账、所有权变更
- **Approvals**: 单个授权 (`approve`/`getApproved`) + 操作员授权 (`setApprovalForAll`/`isApprovedForAll`)
- **Enumeration**: `totalSupply`, `tokenByIndex`, `tokenOfOwnerByIndex`
- **ERC-165**: 接口检测
- **Edge cases**: 零地址回滚、访问不存在的 token、自我授权、防止重复铸造
- **Receiver contracts**: ERC-721 receiver 接收,ERC-721 拒绝者合约回滚
- **Fuzz tests**: 多地址铸造 + 枚举 (`vm.assume`)
### 前端测试
```
make frontend-test
```
运行 Vitest + React Testing Library 冒烟测试:
- 验证“连接钱包”落地页是否正确渲染
- 检查是否存在正确的标题和 CTA 按钮
## 📜 智能合约:ManualNFT
### 工作原理(技术深潜)
每一个 ERC-721 函数都是手工实现的 —— **没有 OpenZeppelin 导入**:
```
ManualNFT
├── State Ledgers
│ ├── _owners: tokenId → address (ownership)
│ ├── _balances: owner → uint256 (balance tracking)
│ ├── _tokenApprovals: tokenId → address (per-token approval)
│ ├── _operatorApprovals: owner → mapping (operator approvals)
│ ├── _tokenURIs: tokenId → string (metadata URIs)
│ ├── _ownedTokens: owner → array (enumeration helper)
│ ├── _ownedTokensIndex: tokenId → index (enumeration helper)
│ ├── _allTokens: array (global token list)
│ └── _allTokensIndex: tokenId → index (global index)
│
├── Core Operations
│ ├── mint(to, uri) → tokenId
│ ├── transferFrom(from, to, tokenId)
│ ├── safeTransferFrom(from, to, tokenId, data)
│ ├── approve(to, tokenId)
│ └── setApprovalForAll(operator, approved)
│
├── View Functions
│ ├── name() / symbol()
│ ├── tokenURI(tokenId)
│ ├── balanceOf(owner)
│ ├── ownerOf(tokenId)
│ ├── getApproved(tokenId)
│ ├── isApprovedForAll(owner, operator)
│ ├── supportsInterface(interfaceId) → ERC-165
│ ├── totalSupply() → Enumerable
│ ├── tokenByIndex(index) → Enumerable
│ └── tokenOfOwnerByIndex(owner, index) → Enumerable
│
└── Internal
├── _mint(to, tokenId)
├── _beforeTokenTransfer(from, to, tokenId)
├── _checkOnERC721Received(operator, from, to, tokenId, data)
└── _baseURI()
```
**Custom errors**(gas 优化,无字符串断言):
- `NFT__NotAuthorized()` — 调用者缺乏权限
- `NFT__NonexistentToken()` — 查询已销毁/不存在的 token
- `NFT__InvalidOwner()` — owner 地址不匹配
- `NFT__InvalidRecipient()` — 铸造/转账至零地址
- `NFT__NonERC721Receiver()` — 合约未实现 `onERC721Received`
### 部署脚本:`--interactives` 工作原理
```
// Priority:
// 1. PRIVATE_KEY environment variable (CI/automated)
// 2. Interactive prompt (--interactives 1, secure)
uint256 deployerPrivateKey = vm.envOr("PRIVATE_KEY", uint256(0));
if (deployerPrivateKey == 0) {
string memory pkHex = vm.promptSecret("Enter private key...");
deployerPrivateKey = vm.parseUint(pkHex);
}
```
`vm.promptSecret` 是一个 Foundry cheatcode,可以安全读取输入,而不会将其回显到终端或存储在 bash 历史记录中。
## 🖥️ 前端架构
### 组件树
```
Providers (WagmiProvider → QueryClient → RainbowKitProvider)
└── Home (page.tsx)
├── Mount guard (hydration safety)
├── [Not connected] Connect Wallet screen
└── [Connected] Dashboard
├── Header (stats bar, network badge, wallet button)
├── Tab bar (Create & Mint | Inventory)
├── Create & Mint (AssetCreationForm)
│ ├── Title / Description / Image upload (drag & drop)
│ ├── IPFS upload (local Desktop or Pinata)
│ ├── Live preview panel
│ └── Mint button + toast notifications
└── Inventory (InventoryPanel)
├── Token grid with IPFS-resolved images
└── Refresh button
```
### IPFS 上传流程(详细)
```
┌─────────────────────────────────────────────────────────┐
│ handleMint() │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 1. setIsUploadingIPFS(true) │ │
│ │ 2. uploadToIPFS(selectedFile) │ │
│ │ ├─ provider === "local" │ │
│ │ │ └─ POST /api/v0/add to 127.0.0.1:5001 │ │
│ │ └─ provider === "pinata" │ │
│ │ └─ POST pinFileToIPFS to api.pinata.cloud │ │
│ │ 3. Returns { cid, uri: "ipfs://" } │ │
│ │ 4. Build metadata: { image: "ipfs://" } │ │
│ │ 5. Base64-encode metadata → tokenURI │ │
│ │ 6. mintToken({ args: [address, uri] }) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ useInventory() (fetchInventory) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 1. balanceOf(address) → count │ │
│ │ 2. For each token: │ │
│ │ ├─ tokenOfOwnerByIndex(owner, i) → tokenId │ │
│ │ ├─ tokenURI(tokenId) → "data:..." │ │
│ │ ├─ atob() → JSON.parse → metadata │ │
│ │ └─ resolveIPFS(metadata.image) → gateway URL │ │
│ └───────────────────────────────────────────────────┘ │
│ Returns TokenInfo[] with resolved image URLs │
└─────────────────────────────────────────────────────────┘
```
### 状态管理
- **钱包状态**: Wagmi 的 `useAccount()` —— 响应式地址 + 连接状态
- **合约读取**: 生成的 `useReadManualNft*` hooks(在地址/链更改时自动重新获取)
- **合约写入**: 生成的 `useWriteManualNftMint` hook(返回交易哈希)
- **IPFS 上传**: 自定义 `uploadToIPFS()` —— 异步,显示加载状态,错误会传播到 toast
- **库存**: 自定义 `useInventory` hook —— 遍历每个 token 的 `tokenOfOwnerByIndex` + `tokenURI`,解析 IPFS URI
- **本地状态**: 用于表单字段、toast、UI 标签页的 React `useState`
### 类型安全的合约绑定 (`@wagmi/cli`)
文件 `frontend/wagmi.config.ts` 使用 Foundry 插件读取 `contracts/out/ManualNFT.sol/ManualNFT.json` 并生成 `frontend/src/generated.ts`。这意味着:
- ✅ **完整的 TypeScript 类型** —— 函数名称、输入、输出、事件、错误
- ✅ **React hooks** —— `useReadManualNft*`, `useWriteManualNft*`, `useSimulateManualNft*`, `useWatchManualNft*`
- ✅ **零 ABI 复制粘贴** —— 在任何合约更改后通过 `make wagmi-gen` 重新生成
## 🔄 部署流水线(自动地址提取)
当你运行 `make deploy-anvil` 或 `make deploy-sepolia` 时:
1. **`forge script`** 部署合约并将广播日志写入 `contracts/broadcast/DeployManualNFT.s.sol//run-latest.json`
2. **`scripts/extract-address.sh`** 解析 JSON 并提取 `contractAddress`
3. 脚本将 `NEXT_PUBLIC_CONTRACT_ADDRESS=` 写入 `frontend/.env.local`
4. **`make wagmi-gen`** 重新生成类型安全的 hooks
你永远不需要手动复制粘贴合约地址。
## 📦 Makefile 命令
| 命令 | 描述 |
|---|---|
| **`make install-all`** | 安装所有依赖(合约 + 前端) |
| **`make contracts-build`** | 使用 forge 编译 Solidity |
| **`make contracts-test`** | 运行 34 个合约测试 (+vvvv) |
| **`make wagmi-gen`** | 构建合约 + 生成 React hooks |
| **`make deploy-anvil`** | 部署到 localhost:8545 + 注入地址 |
| **`make deploy-sepolia`** | 部署到 Sepolia + 注入地址 |
| **`make post-deploy-setup`** | 提取地址 + 重新生成类型 |
| **`make frontend-dev`** | 启动 Next.js 开发服务器 |
| **`make frontend-build`** | 生产环境构建 |
| **`make frontend-test`** | 运行 Vitest 前端测试 |
| **`make dev-env`** | 构建 → 类型 → 开发服务器 |
| **`make clean`** | 移除构建产物 |
## ⚙️ 环境变量
### `frontend/.env.local`
| 变量 | 必需 | 默认值 | 描述 |
|---|---|---|---|
| `NEXT_PUBLIC_WC_PROJECT_ID` | ✅ 是 | — | WalletConnect Project ID |
| `NEXT_PUBLIC_ANVIL_RPC` | ❌ 否 | `http://localhost:8545` | Anvil RPC URL |
| `NEXT_PUBLIC_SEPOLIA_RPC` | 用于 Sepolia | — | Sepolia RPC URL |
| `NEXT_PUBLIC_CONTRACT_ADDRESS` | 部署后 | — | 由 `make deploy-*` 自动设置 |
| `NEXT_PUBLIC_IPFS_PROVIDER` | ❌ 否 | `local` | `local` 或 `pinata` |
| `NEXT_PUBLIC_LOCAL_IPFS_API` | ❌ 否 | `http://127.0.0.1:5001` | IPFS Desktop API URL |
| `NEXT_PUBLIC_LOCAL_IPFS_GATEWAY` | ❌ 否 | `http://127.0.0.1:8080` | IPFS Desktop 网关 URL |
| `NEXT_PUBLIC_PINATA_JWT` | 用于 Pinata | — | Pinata JWT token |
| `NEXT_PUBLIC_PINATA_GATEWAY` | ❌ 否 | `https://gateway.pinata.cloud` | Pinata 网关 URL |
### 根目录 `.env`
| 变量 | 必需 | 描述 |
|---|---|---|
| `SEPOL_RPC` | 用于 Sepolia 部署 | `forge script` 使用的 RPC URL |
标签:DApp, NFT, Solidity, Web3.0, 以太坊, 区块链, 智能合约, 自动化攻击