项目概览

  • 项目名称: 奶油社区 (Creamunity)

  • 技术栈:

    • 区块链: Ethereum Sepolia 测试网

    • 智能合约语言: Solidity

    • 开发环境: Hardhat

    • 前端库: Ethers.js

  • 核心流程:

    1. 发行代币 (CreamCoin): 创建一个标准的 ERC-20 代币。

    2. 创建社交合约 (Creamunity): 负责处理发帖、打赏逻辑。

    3. 开发前端页面: 用户进行交互的界面。

    4. 实现核心功能: 创建钱包、登录、查余额、发帖 (消耗币并上链)、看帖、打赏。

    5. Gas 费用代付: 后端服务器为用户的操作代付 gas 费用。

准备工作与环境搭建

1. 初始化项目

打开终端 PowerShell 创建项目。

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 创建一个项目文件夹
mkdir creamunity
cd creamunity

# 2. 初始化 Node.js 项目
npm init -y

# 3. 安装 Hardhat - 专业的以太坊开发环境
npm install --save-dev hardhat

# 4. 启动 Hardhat
npx hardhat

启动 Hardhat 时,它会询问:

  • 选择 Create a JavaScript project

  • Hardhat project root: 直接回车。

  • Do you want to add a .gitignore?: y (yes)。

  • Do you want to install sample project's dependencies with npm?: y (yes)。

阶段一:发行代币 (Token)

代币是项的经济基础,创建一个标准的 ERC-20 代币。为了安全和简单,使用 OpenZeppelin 提供的标准合约模板。

1.安装 OpenZeppelin 合约库

1
npm install @openzeppelin/contracts

2.创建 CreamCoin.sol 合约

contracts/ 文件夹下,创建一个新文件 CreamCoin.sol,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol"; // 引入 Ownable

// 标准的 ERC-20 代币合约
// 继承 OpenZeppelin 的 ERC20 和 Ownable 合约
contract CreamCoin is ERC20, Ownable {

// 构造函数,在部署时被调用一次
// initialOwner 是部署这个合约的钱包地址
constructor(address initialOwner) ERC20("Cream Coin", "CREAM") Ownable(initialOwner) {
// 在这里可以给自己预先铸造一些币
// _mint(接收者地址, 数量)
// 1000000 * 10^18 (ERC20 默认 18 位小数)
_mint(msg.sender, 1000000 * 10**decimals());
}

// 提供一个公开的 mint 函数,只有合约拥有者(Owner)可以调用
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}

代码解释:

  • ERC20("Cream Coin", "CREAM"): 定义了代币的全名和简称。

  • Ownable(initialOwner): 设置部署者为合约的”拥有者”,只有拥有者能调用 onlyOwner 修饰的函数。

  • _mint(msg.sender, ...): 在合约部署时,给自己的钱包里铸造 1,000,000 个奶油币。

  • mint(...) 函数: 允许以后继续增发,比如给 faucet 或者新用户。

3.配置部署环境

需要告诉 Hardhat 如何连接到 Sepolia 测试网。

  • 获取 RPC URL: Infura,创建一个新的 App,选择 Ethereum Sepolia 网络,然后复制其 HTTPS RPC URL。

  • 获取私钥: 从 MetaMask 导出私钥。点击账户详情 -> 导出私钥。

  • 安装 dotenv: 为了安全地管理私钥和 URL,使用 .env 文件。

1
npm install dotenv

创建 .env 文件**: 在项目根目录创建 .env 文件,并写入:

1
2
SEPOLIA_RPC_URL="_ALCHEMY_或_INFURA_的_URL"
PRIVATE_KEY="_METAMASK_私钥"

修改 hardhat.config.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config(); // 引入 dotenv

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.20",
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL || "",
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
},
};

4. 编写部署脚本

在 scripts/ 文件夹下,创建一个新文件 deployCreamCoin.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const hre = require("hardhat");

async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);

// 部署 CreamCoin 合约
const creamCoin = await hre.ethers.deployContract("CreamCoin", [deployer.address]);

await creamCoin.waitForDeployment();

console.log(
`CreamCoin deployed to: ${creamCoin.target}`
);
}

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

5. 部署!

运行以下命令,将代币部署到 Sepolia 测试网。

1
npx hardhat run scripts/deployCreamCoin.js --network sepolia

成功后,终端会输出 CreamCoin deployed to: 0x...。**这个 0x... 地址就是的奶油币合约地址,请务必保存好!可以在 Sepolia Etherscan 上搜索合约地址,查看它的信息。

阶段二:创建社交合约 (Creamunity)

这个合约是项目的核心,处理留言和打赏。

1. 创建 Creamunity.sol 合约

contracts/ 文件夹下创建 Creamunity.sol

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // 引入 ERC20 接口

contract Creamunity {
// --- 状态变量 ---

IERC20 public creamCoin; // 奶油币合约的引用
address public burnAddress = 0x000000000000000000000000000000000000dEaD; // 黑洞地址
uint256 public postFee = 10 * 10**18; // 每次发言消耗10个币 (假设18位小数)
uint256 public tipAmount = 1 * 10**18; // 每次打赏固定1个币

// --- 留言结构体 ---
struct Message {
uint256 id;
string content; // 留言内容
address author; // 作者地址
uint256 timestamp; // 发布时间
uint256 totalTips; // 收到的总打赏
}

// 用一个数组存储所有留言
Message[] public messages;
// 用于快速通过 id 查找留言在数组中的索引
mapping(uint256 => uint256) private messageIndexById;

// --- 事件 ---
// 方便前端监听新消息和打赏
event NewMessage(uint256 id, address indexed author, string content, uint256 timestamp);
event NewTip(uint256 indexed messageId, address indexed tipper, address indexed author);

// --- 构造函数 ---
constructor(address _creamCoinAddress) {
creamCoin = IERC20(_creamCoinAddress);
}

// --- 核心功能:发布留言 ---
function postMessage(string memory _content) public {
// 1. 检查用户是否有足够的奶油币并已授权
// transferFrom 会检查授权额度,如果不足或未授权,会自动失败
// 2. 从用户钱包转 10 个币到黑洞地址 (销毁)
bool sent = creamCoin.transferFrom(msg.sender, burnAddress, postFee);
require(sent, "Token transfer failed");

// 3. 创建新留言
uint256 messageId = messages.length;
messages.push(Message({
id: messageId,
content: _content,
author: msg.sender,
timestamp: block.timestamp,
totalTips: 0
}));
messageIndexById[messageId] = messageId; // 在这个简单模型里,id和index相同

// 4. 触发新留言事件
emit NewMessage(messageId, msg.sender, _content, block.timestamp);
}

// --- 核心功能:打赏留言 ---
function tipMessage(uint256 _messageId) public {
// 确保留言存在
require(_messageId < messages.length, "Message does not exist");

Message storage messageToTip = messages[messageIndexById[_messageId]];

// 打赏者不能是作者本人
require(msg.sender != messageToTip.author, "Cannot tip your own message");

// 1. 从打赏者钱包转 1 个币给留言作者
bool sent = creamCoin.transferFrom(msg.sender, messageToTip.author, tipAmount);
require(sent, "Token transfer failed");

// 2. 更新留言的总打赏额
messageToTip.totalTips += tipAmount;

// 3. 触发打赏事件
emit NewTip(_messageId, msg.sender, messageToTip.author);
}

// --- 读取功能 ---
function getAllMessages() public view returns (Message[] memory) {
return messages;
}

function getMessageCount() public view returns (uint256) {
return messages.length;
}
}

2. 部署 Creamunity 合约

创建部署脚本 scripts/deployCreamunity.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const hre = require("hardhat");

async function main() {
const [deployer] = await hre.ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);

// 这里需要填入刚才部署的 CreamCoin 合约地址
const creamCoinAddress = "_CREAMCOIN_合约地址";

const creamunity = await hre.ethers.deployContract("Creamunity", [creamCoinAddress]);

await creamunity.waitForDeployment();

console.log(
`Creamunity deployed to: ${creamunity.target}`
);
}

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

然后运行部署命令:

1
npx hardhat run scripts/deployCreamunity.js --network sepolia

保存好输出的 Creamunity 合约地址。现在两个核心合约都已上线。

阶段三:开发前端界面

前端仓库已部署到远程仓库

阶段四:GAS 费用代付

这个模型将一次用户操作拆分成了三个步骤:

  1. 前端

    • 用户点击“发布”按钮。

    • 前端向后端服务器**发送一个请求,告知“用户 0xABC... 需要一笔 Gas 费来发帖”。

  2. 后端(服务器)

    • 服务器接收到请求,验证其合法性。

    • 服务器动用开发者钱包(里面有 Sepolia ETH),向用户 0xABC... 的地址发送一笔 ETH。

    • 服务器等待这笔转账交易被确认,然后告诉前端“Gas 已到账”。

  3. 前端

    • 接收到后端“已到账”的通知。

    • 像之前一样,正常调用 approvepostMessage 函数。因为用户的钱包里刚刚收到了 ETH,所以用户现在有能力支付这笔操作的 Gas 费了。

创建后端自动转账脚本

使用 Node.js 和 Express 创建一个简单的后台服务,它只有一个功能:接收前端请求并给用户转账 ETH。

在项目根目录 (creamunity) 下创建新文件夹并初始化

1
2
3
4
mkdir backend
cd backend
npm init -y
npm install express ethers dotenv cors winston
  • express: 用于创建 web 服务。

  • ethers: 用于和以太坊交互。

  • dotenv: 用于安全管理开发者私钥。

  • cors: 解决跨域请求问题。

  • winston: 输出日志

backend 文件夹中创建 .env 文件 这个文件用来存放开发者钱包私钥

1
2
3
4
5
# 开发者钱包私钥
DEVELOPER_PRIVATE_KEY="开发者钱包私钥"

# Sepolia RPC URL
SEPOLIA_RPC_URL="SEPOLIA_RPC_URL"

backend 文件夹中创建 server.js 文件

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const express = require('express');
const ethers = require('ethers');
const cors = require('cors'); // 引入cors
require('dotenv').config();

const app = express();
app.use(cors()); // 允许所有跨域请求
app.use(express.json()); // 解析JSON请求体

const PORT = 3000;

// --- 初始化开发者钱包 ---
const provider = new ethers.providers.JsonRpcProvider(process.env.SEPOLIA_RPC_URL);
const developerWallet = new ethers.Wallet(process.env.DEVELOPER_PRIVATE_KEY, provider);

console.log(`自动化Gas支援服务启动...`);
console.log(`开发者钱包地址: ${developerWallet.address}`);

// --- API 端点:/request-gas ---
app.post('/request-gas', async (req, res) => {
const { userAddress, amount } = req.body;

if (!ethers.utils.isAddress(userAddress) || !amount) {
return res.status(400).json({ message: '无效的地址或金额' });
}

console.log(`收到请求: 向 ${userAddress} 转账 ${ethers.utils.formatEther(amount)} ETH`);

try {
const tx = {
to: userAddress,
value: ethers.BigNumber.from(amount), // 金额必须是 BigNumber
};

// 开发者钱包发送交易
const txResponse = await developerWallet.sendTransaction(tx);
console.log(`交易已发送,Hash: ${txResponse.hash}`);

// 注意:这里立即返回了hash,没有等待交易确认,以提高前端响应速度
// 前端会使用 provider.waitForTransaction 来等待
res.status(200).json({
message: '转账请求已发送',
txHash: txResponse.hash,
});

} catch (error) {
console.error('转账失败:', error);
res.status(500).json({ message: '服务器内部错误,转账失败' });
}
});

app.listen(PORT, () => {
console.log(`服务器正在监听端口 ${PORT}`);
});

创建日志配置文件 (logger.js)

为了让代码更整洁,把所有日志相关的配置放进一个单独的文件里。

backend 文件夹中,创建一个新的JavaScript文件,命名为 logger.js

将以下代码粘贴到 logger.js 文件中:

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
35
36
// backend/logger.js

const winston = require('winston');

// 自定义日志格式
const logFormat = winston.format.printf(({ level, message, timestamp }) => {
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
});

const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),

transports: [
new winston.transports.File({
filename: 'server.log',
level: 'info'
}),

new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
level: 'info'
})
],

exceptionHandlers: [
new winston.transports.File({ filename: 'exceptions.log' })
]
});

module.exports = logger;

现在会看到:

  • 控制台输出:终端里的所有日志,现在每一行前面都会有 2025-06-13 15:52:00 [INFO]: 这样的格式,并且带有颜色。

  • 新的日志文件:在 backend 文件夹下,会自动创建一个名为 server.log 的文件。

  • 文件内容:打开 server.log,会发现里面记录了和控制台完全一样的、带有时间戳的日志信息。

阶段五:上线后端服务

安装 Nginx ,用来作为反向代理。

1
sudo apt install nginx -y

安装 PM2 (进程管理器)

如果直接用 node server.js 运行的后端,当关闭SSH连接时,程序就会中断。PM2是一个进程管理器,它能让的Node.js应用在后台稳定运行,并在服务器重启后自动启动。

1
sudo npm install -g pm2

现在需要把本地的 creamunity 文件夹整个上传到VPS上。

  • 将本地的 creamunity 项目推送到一个私有的GitHub或GitLab仓库。

  • 在VPS上安装Git:sudo apt install git -y

  • 在VPS上找一个合适的位置(如 /var/www/)克隆项目:

1
2
3
4
5
6
# 创建目录
sudo mkdir -p /var/www
sudo chown -R $(whoami) /var/www
cd /var/www
# 克隆项目
git clone GIT_REPOSITORY_URL

以后更新代码,只需要在服务器上运行 git pull 即可。

代码上传后,需要配置并启动后端。.env文件因为包含密钥,通常不会上传到Git。需要在服务器上手动创建它。

1
2
3
cd /var/www/creamunity/backend
npm install
nano .env

在打开的编辑器中,把你本地 .env 文件里的所有内容(DEVELOPER_PRIVATE_KEY, ETHERSCAN_API_KEY等)复制粘贴进去。按 Ctrl+X,然后按 Y,再按回车来保存并退出。

用 PM2 启动后端服务

1
pm2 start server.js --name "cream-backend"

--name 参数为应用起了一个名字,方便管理。

配置 Nginx (反向代理)

这是最关键的一步,要告诉Nginx:

  • 当用户访问的IP时,显示 frontend 文件夹里的静态文件。

  • 当有发往 /api//request- 等路径的请求时,将这些请求转发给正在 localhost:3000 运行的后端Node.js服务。

创建 Nginx 配置文件:

1
sudo nano /etc/nginx/sites-available/creamunity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 80;
listen [::]:80;

server_name api.domain.com;


location / {
# 将所有请求都转发给后端Node.js服务
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}

最后启用配置

1
2
3
sudo rm /etc/nginx/sites-enabled/default
# 创建一个从 available 到 enabled 的符号链接来启用配置
sudo ln -s /etc/nginx/sites-available/creamunity/etc/nginx/sites-enabled/

测试并重启 Nginx

1
2
sudo nginx -t  # 测试配置语法是否有错误
sudo systemctl restart nginx # 重启Nginx使配置生效

现在服务已经可以通过域名访问,请务必为API启用HTTPS加密。在VPS上运行 certbot (在之前的步骤中已经安装过):

1
2
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d api.your-domain.com

Certbot会引导完成几个简单的步骤,然后它会自动获取SSL证书,并自动修改Nginx配置以启用HTTPS。完成后,所有到 http://api.your-domain.com 的请求都会被自动重定向到 https://

至此,一个全栈DApp社区项目从本地部署到了线上生产环境