DeVote 是一个去中心化投票系统,它的后端使用 Node.js,数据层使用以太坊智能合约。传统的投票系统通常把选票存在关系型数据库里,管理员拥有最高权限,可以修改或删除记录。DeVote 则把候选人和选票状态存放在合约存储中,任何一次投票都会生成交易并被打包进区块,节点可以独立验证。Node.js 在系统中扮演中间层,负责解析请求、调用合约读写方法、监听事件并向客户端返回格式化数据。

一、系统架构与核心依赖
DeVote 的整体架构可以分为三层:以太坊节点层、Node.js 服务层和客户端展示层。以太坊节点负责维护合约状态、执行交易和广播事件;Node.js 服务通过 ethers.js 库连接节点,把合约的方法封装成 RESTful 接口;客户端则调用这些接口完成候选人注册、投票和结果查看。这种分层方式让合约逻辑与 Web 服务解耦,即使前端框架发生变化,合约和 Node.js 服务依然可以复用。
在开始编码之前,需要安装几个基础依赖。Node.js 版本建议使用 18 或更高版本,因为 ethers.js 6.x 依赖现代 JavaScript 特性。合约开发使用 Hardhat,它提供了本地测试网络、编译工具和部署脚本。初始化项目后,安装 ethers.js 和 express 两个核心包。ethers.js 负责与区块链交互,express 用来提供 HTTP 接口。本地测试时可以使用 Hardhat Network,不需要同步真实的以太坊主网,也不需要购买测试代币。
配置文件方面,Node.js 服务需要读取三个关键环境变量:RPC_URL、PRIVATE_KEY 和 CONTRACT_ADDRESS。RPC_URL 指向以太坊节点的访问地址,例如本地 Hardhat 节点的 http://127.0.0.1:8545。PRIVATE_KEY 是部署合约或发送交易的账户私钥,绝对不要提交到代码仓库。CONTRACT_ADDRESS 是合约部署后的链上地址,由部署脚本自动生成或手动写入。将这些变量放在 .env 文件中,通过 dotenv 加载,可以避免敏感信息泄露。
二、使用Solidity编写投票合约
智能合约是 DeVote 的核心,它定义了候选人结构、投票规则和事件日志。合约使用 Solidity 编写,部署后代码不可修改,因此需要在部署前充分测试。以下是一个最小可用的投票合约,它包含候选人注册、投票和查询功能。候选人信息通过 struct 类型保存,包含 id、名称和得票数。mapping 结构把候选人 id 映射到具体数据,同时记录每个地址是否已经投过票。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DeVote {
struct Candidate {
uint id;
string name;
uint voteCount;
}
mapping(uint => Candidate) public candidates;
mapping(address => bool) public hasVoted;
uint public candidatesCount;
event VoteCast(uint indexed candidateId, address indexed voter);
function addCandidate(string memory name) public {
candidatesCount++;
candidates[candidatesCount] = Candidate(candidatesCount, name, 0);
}
function vote(uint candidateId) public {
require(!hasVoted[msg.sender], "Already voted");
require(candidateId > 0 && candidateId <= candidatesCount, "Invalid candidate");
candidates[candidateId].voteCount++;
hasVoted[msg.sender] = true;
emit VoteCast(candidateId, msg.sender);
}
function getCandidate(uint candidateId) public view returns (uint, string memory, uint) {
Candidate memory c = candidates[candidateId];
return (c.id, c.name, c.voteCount);
}
}
合约中的 vote 函数包含两个 require 校验:第一个检查调用者是否已经投过票,第二个检查候选人 id 是否在有效范围内。这种写法可以防止同一个地址重复投票,也能避免传入不存在的候选人 id 导致异常。所有状态修改都是在交易被矿工打包后生效,因此投票操作具有最终性。事件 VoteCast 会在投票成功时触发,前端可以通过监听该事件实时更新界面。
部署合约时,可以使用 Hardhat 的部署脚本。脚本需要获取签名者账户,然后调用合约工厂的 deploy 方法。部署完成后,合约地址会打印在控制台。对于本地测试网络,每次重启节点后合约地址都会重置,因此 Node.js 服务应该在节点启动后读取最新地址。如果在测试网络上部署,需要保证账户中有足够的测试代币来支付 Gas 费用。
三、Node.js后端连接以太坊
Node.js 服务通过 ethers.js 的 JsonRpcProvider 连接以太坊节点。Provider 负责读取链上数据,例如查询候选人信息和当前票数。如果只执行读操作,不需要提供账户私钥。但 DeVote 需要添加候选人和投票,这些操作会改变合约状态,必须使用一个带私钥的钱包对象来签名交易。wallet 需要连接到 provider,这样它才能广播交易并等待确认。
const { ethers } = require("ethers");
require("dotenv").config();
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
const contractAddress = process.env.CONTRACT_ADDRESS;
const contractABI = require("./DeVoteABI.json");
const contract = new ethers.Contract(contractAddress, contractABI, wallet);
async function addCandidate(name) {
const tx = await contract.addCandidate(name);
await tx.wait();
return tx.hash;
}
async function getCandidates() {
const count = await contract.candidatesCount();
const result = [];
for (let i = 1; i <= count; i++) {
const candidate = await contract.getCandidate(i);
result.push({
id: Number(candidate[0]),
name: candidate[1],
voteCount: Number(candidate[2])
});
}
return result;
}
ABI 是合约的应用程序二进制接口,它描述了合约中有哪些函数、参数类型和返回值。Hardhat 编译合约后会在 artifacts 目录生成包含 ABI 的 JSON 文件,Node.js 服务可以直接引用。如果合约升级或修改,ABI 文件也需要同步更新,否则调用会失败。上面的 getCandidates 函数遍历所有候选人,把链上返回的大整数转换为 JavaScript 的 Number 类型,避免前端解析出现问题。
写操作和读操作的区别非常重要。addCandidate 和 vote 会产生交易,需要消耗 Gas,并且不会立即返回最终状态。tx.wait() 会等待交易被包含进区块,适合需要确认结果的场景。读操作如 candidatesCount 和 getCandidate 不会消耗 Gas,也不会修改链上数据,调用后可以立即得到结果。Node.js 服务应该根据接口性质选择对应的调用方式。
四、投票接口与防重复机制
为了让前端方便调用,Node.js 服务需要把合约方法封装成 HTTP 接口。添加候选人接口接收 name 参数,投票接口接收 candidateId 参数。在投票接口中,除了调用合约的 vote 方法,还可以在服务端做一层预检查,例如先查询 hasVoted 状态,如果该钱包已经投过票,直接返回 400 错误,避免产生不必要的 Gas 消耗。这种预检查不能代替合约层的校验,因为多个请求并发时仍可能产生竞争,但可以提升用户体验。
app.post("/vote", async (req, res) => {
const candidateId = Number(req.body.candidateId);
try {
const voted = await contract.hasVoted(wallet.address);
if (voted) {
return res.status(400).json({ error: "This wallet has already voted" });
}
const tx = await contract.vote(candidateId);
await tx.wait();
res.json({ success: true, txHash: tx.hash });
} catch (err) {
console.error(err);
res.status(400).json({ error: err.message });
}
});
实际项目中,投票接口通常需要与用户身份绑定。如果所有投票都使用同一个服务端钱包签名,那么 hasVoted 状态只能记录服务端钱包是否投过票,无法区分不同用户。解决方式有两种:一种是让前端通过 MetaMask 直接签名交易,Node.js 只提供合约地址和 ABI;另一种是为每个用户创建一个链上钱包,由服务端管理私钥,但这样做安全成本较高。对于演示和学习目的,使用服务端钱包已经足够,重点在于理解合约状态变化和事件机制。
Gas 费用是链上投票的一个重要考虑因素。每次投票都会产生交易费,如果部署在以太坊主网,成本可能较高。开发阶段可以使用测试网络或本地节点,Gas 费用几乎为零。正式上线时,可以选择 Polygon、Arbitrum 等 Gas 费用较低的兼容 EVM 链。合约代码本身不需要修改,只需要在 Node.js 配置中修改 RPC_URL 和重新部署合约即可。
五、监听事件与结果统计
以太坊合约可以通过事件向外部应用推送状态变化。DeVote 合约中定义了 VoteCast 事件,投票成功时会自动触发。Node.js 服务使用 contract.on 方法订阅该事件,一旦收到新区块中的日志,就执行回调函数。回调函数可以更新内存中的缓存,或者通过 WebSocket 推送给前端,让前端在不刷新页面的情况下看到最新票数。事件监听不会消耗 Gas,是获取实时数据的高效方式。
contract.on("VoteCast", (candidateId, voter) => {
console.log(`Candidate ${candidateId} received vote from ${voter}`);
updateLocalCache(Number(candidateId));
});
事件监听需要保持 WebSocket 连接,某些 RPC 提供商可能不支持 WebSocket,可以使用轮询作为替代方案。轮询方式每隔一段时间调用 getCandidates 获取最新票数,实现简单但实时性较差。事件日志的另一个优势是历史可追溯,即使服务端重启,也可以从最早的区块开始重新扫描日志,恢复完整的投票记录。前端可以通过查询过滤条件,只获取某些候选人相关的事件。
统计结果时,不应该只依赖前端展示的票数,因为前端数据可能被篡改。最可靠的方式是每次查询都直接调用合约的 getCandidate 方法,读取链上存储的票数。Node.js 服务可以提供一个聚合接口,一次性返回所有候选人的 id、名称和票数,前端负责渲染成表格或图表。对于大规模投票,还可以把统计数据缓存到 Redis 中,通过监听事件刷新缓存,降低链上查询压力。
经过以上几个步骤,一个完整的 DeVote 链上投票系统就可以运行起来。这个系统证明了 Node.js 与智能合约结合并不复杂,关键是理解合约状态、交易签名、事件监听和 Gas 成本这些区块链特有的概念。把这个基础项目作为起点,你可以扩展候选人审核、投票时间窗口、匿名投票等功能,逐步构建更完善的去中心化应用。