DApp 开发与 NFT 示例

DApp 由智能合约、链上网络、钱包连接、前端应用和链下基础设施共同组成。开发重点不是单独完成合约或页面,而是保证 ABI、地址、链 ID、交易状态和权限边界在各层保持一致。

总体架构

1
2
3
4
5
6
7
8
9
10
11
flowchart LR
U[用户] --> W[浏览器钱包]
U --> F[前端应用]
F --> W
W --> R[RPC 节点]
F --> R
R --> C[智能合约]
C --> E[事件日志]
E --> I[索引服务]
I --> F
F --> M[IPFS 或对象存储]

开发流程

  1. 明确业务状态、参与角色和信任边界。
  2. 编写最小合约并建立单元测试与不变量测试。
  3. 编写部署脚本,固定网络、构造参数与管理员地址。
  4. 导出 ABI 和部署地址,生成前端类型。
  5. 实现钱包连接、读操作、交易模拟、签名和回执跟踪。
  6. 在本地网络与测试网执行端到端测试。
  7. 完成权限复核、安全审计、合约验证和前端部署。
  8. 建立事件监控、错误告警与升级处置方案。

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
dapp/
├── contracts/
│ └── NoteNFT.sol
├── scripts/
│ └── deploy.ts
├── test/
│ └── NoteNFT.test.ts
├── frontend/
│ ├── src/
│ └── public/
├── deployments/
└── hardhat.config.ts

合约、测试和部署脚本应位于同一可重复构建的工程中。前端通过构建步骤读取 ABI 与部署清单,避免手工复制导致版本不一致。

NFT 合约示例

以下示例使用成熟库实现基础 ERC-721 铸造,并通过访问控制限制铸造权限。依赖版本应在项目锁文件中固定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract NoteNFT is ERC721URIStorage, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
uint256 private nextTokenId;

constructor(address admin) ERC721("Note NFT", "NOTE") {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
}

function mint(address receiver, string calldata metadataUri)
external
onlyRole(MINTER_ROLE)
returns (uint256 tokenId)
{
tokenId = nextTokenId++;
_safeMint(receiver, tokenId);
_setTokenURI(tokenId, metadataUri);
}

function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721URIStorage, AccessControl)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}

生产合约还应明确最大供应量、元数据是否可变、管理员轮换方式、暂停机制和版税规则。若元数据依赖中心化 URL,资产长期可用性将受服务商影响。

测试重点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { expect } from "chai";
import { ethers } from "hardhat";

it("allows only a minter to mint", async () => {
const [admin, user, receiver] = await ethers.getSigners();
const factory = await ethers.getContractFactory("NoteNFT");
const nft = await factory.deploy(admin.address);

await expect(
nft.connect(user).mint(receiver.address, "ipfs://metadata")
).to.be.reverted;

await expect(nft.mint(receiver.address, "ipfs://metadata"))
.to.emit(nft, "Transfer")
.withArgs(ethers.ZeroAddress, receiver.address, 0n);
});

至少覆盖权限、零地址、重复操作、供应量边界、元数据、接口支持和安全转账接收器。测试通过只能证明已覆盖行为,不等同于合约不存在漏洞。

部署脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ethers } from "hardhat";

async function main() {
const [deployer] = await ethers.getSigners();
const factory = await ethers.getContractFactory("NoteNFT");
const contract = await factory.deploy(deployer.address);
await contract.waitForDeployment();

console.log(JSON.stringify({
chainId: (await ethers.provider.getNetwork()).chainId.toString(),
address: await contract.getAddress(),
deployer: deployer.address,
}));
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

部署后应保存链 ID、合约地址、交易哈希、编译器版本、优化参数和 Git 提交。地址不能只写在前端源码中,应生成按网络划分的部署清单。

前端读取与写入

读取操作不需要用户发送交易,可通过公共 RPC 客户端完成。写操作需要连接钱包、确认网络、模拟交易并等待回执。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { BrowserProvider, Contract } from "ethers";
import abi from "./abi/NoteNFT.json";

const provider = new BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const contract = new Contract(contractAddress, abi, signer);

const transaction = await contract.mint(
await signer.getAddress(),
"ipfs://bafy.../metadata.json"
);
const receipt = await transaction.wait();
if (receipt?.status !== 1) {
throw new Error("transaction failed");
}

前端需要展示等待钱包确认、交易已广播、链上确认和交易失败等不同状态。用户拒签、余额不足、链错误、nonce 冲突和交易被替换都应有明确提示。

交易状态

1
2
3
4
5
6
7
8
9
10
11
12
stateDiagram-v2
[*] --> Preparing
Preparing --> AwaitingSignature
AwaitingSignature --> Rejected
AwaitingSignature --> Pending
Pending --> Confirmed
Pending --> Replaced
Pending --> Failed
Replaced --> Confirmed
Confirmed --> [*]
Rejected --> [*]
Failed --> [*]

元数据

ERC-721 的 tokenURI 通常指向 JSON 元数据。JSON 中的图片 URL 也应采用稳定存储,并保持正确的媒体类型。

1
2
3
4
5
6
7
8
{
"name": "Note NFT 1",
"description": "A verifiable note asset",
"image": "ipfs://bafy.../image.png",
"attributes": [
{ "trait_type": "Category", "value": "Learning" }
]
}

上传后应先验证网关可读取内容,再执行铸造。若元数据不可变,应固定内容地址并避免后续覆盖。

安全检查

  • 使用成熟标准库,避免自行实现代币标准。
  • 管理员权限采用多签或延迟执行,避免单一私钥长期控制。
  • 前端发起交易前展示目标合约、网络、金额和关键参数。
  • 不信任前端校验,合约必须独立执行权限与状态检查。
  • RPC 密钥不等同于钱包私钥,但仍应限制域名、额度与接口范围。
  • 监听合约事件时处理链重组,不要只依赖未确认日志。
  • 升级合约时审查存储布局与初始化函数。

上线与维护

上线顺序通常为合约部署、源码验证、部署清单发布、索引器启动、前端部署和监控启用。发生异常时,应能够暂停前端入口、告警管理员并提供链上交易证据。若合约不可升级,则修复通常需要迁移资产和用户状态,因此发布前的测试与审计尤为重要。