智能合約實戰程式碼範例:常見錯誤與解決方案
本文從實務角度出發,提供可直接複製使用的 Solidity 程式碼範例,涵蓋銀行合約、ERC20 代幣、Ownable 權限控制、Gas 優化等常見場景。特別強調各種安全漏洞的成因與修復方式,包括 reentrancy 攻擊、overflow/underflow、權限繞過等常見錯誤。提供錯誤版本與正確版本的對比分析,幫助開發者建立安全意識與實務能力。
智能合約實戰程式碼範例:常見錯誤與解決方案
前言:看別人的 code 不如自己寫 code
學智能合約開發最大的坑是什麼?
不是 Solidity 語法,不是區塊鏈概念,而是——你寫的合約到底能不能安全運作。
看別人的教學,感覺很簡單對吧?變數一宣告,function 一寫,deploy 就完成了。然後你自己動手,發現一堆 error 要 debug,好不容易 deploy 上去,結果合約被駭了,幾十萬美元就這樣沒了。
這篇文就是要幫你避開那些最常見的坑。我會把實務上最容易犯的錯誤一個個拆解,加上可以直接複製使用的程式碼範例。
第一個合約:簡單的存錢合約
先從最基礎的開始。假設你想要一個合約,讓大家可以存款、領款,而且合約主人可以提取所有資金。
錯誤版本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract BadBank {
address public owner;
mapping(address => uint256) public balances;
constructor() {
owner = msg.sender;
}
// 存款
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 提款
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "余額不足");
payable(msg.sender).transfer(amount);
balances[msg.sender] -= amount;
}
// 合約主人提款
function withdrawAll() public {
payable(owner).transfer(address(this).balance);
}
}
看起來很正常對吧?錯了。這個合約有兩個致命問題。
問題一:withdraw function 的 reentrancy 漏洞
Solidity 的 transfer/send 都只有 2300 gas,如果接收方是合約帳戶,這個呼叫可能會失敗。更糟的是,你減去余額的操作在轉帳「之後」,這給了攻擊者重入攻擊的機會。
問題二:withdrawAll 沒有權限檢查
任何人都可以呼叫 withdrawAll,把所有資金拿走。constructor 裡設定的 owner 在這裡完全沒用到。
正確版本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GoodBank {
address public owner;
mapping(address => uint256) public balances;
// 宣告 Deposit event,方便除錯和追蹤
event Deposit(address indexed user, uint256 amount);
event Withdraw(address indexed user, uint256 amount);
modifier onlyOwner() {
require(msg.sender == owner, "不是合約擁有者");
_;
}
constructor() {
owner = msg.sender;
}
// 存款
function deposit() external payable {
require(msg.value > 0, "存款金額必須大於 0");
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
// 提款 - 使用 Checks-Effects-Interactions 模式防止重入
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "余額不足");
// 先更新狀態,再轉帳
balances[msg.sender] -= amount;
// 轉帳使用 call, 並檢查返回值
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "轉帳失敗");
emit Withdraw(msg.sender, amount);
}
// 合約主人提款 - 加上權限檢查
function withdrawAll() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "合約沒有餘額");
(bool success, ) = payable(owner).call{value: balance}("");
require(success, "轉帳失敗");
}
// 允許合約接收 ETH
receive() external payable {}
}
為什麼這樣是對的?
- Checks-Effects-Interactions:先把余額減掉,再轉帳。這樣即使攻擊者想重入,余額已經不夠了。
- 使用 call 而不是 transfer:transfer 只給 2300 gas,call 可以調整 gas 用量,更靈活。
- 檢查返回值:轉帳可能失敗,你必須檢查 success,否則使用者余額扣了但沒收到錢。
- 使用 modifier 做權限控制:只有 owner 可以呼叫 withdrawAll。
ERC20 代幣合約
第二個常見場景是自己發行一個代幣。Solidity 已經有標準的 ERC20 接口,你的合約只要實作這個接口就行。
錯誤版本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
contract BadToken {
string public name = "Bad Token";
string public symbol = "BAD";
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
function transfer(address to, uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "余額不足");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
}
function approve(address spender, uint256 amount) external {
allowance[msg.sender][spender] = amount;
}
function transferFrom(address from, address to, uint256 amount) external {
require(balanceOf[from] >= amount, "余額不足");
require(allowance[from][msg.sender] >= amount, "未授權");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
}
}
問題在哪裡?
- 沒有宣告 decimals:ERC20 標準規定要有一個 decimals function,預設值是 18。
- transferFrom 的減法可能 overflow:如果 allowance 剛好等於 amount,減完變成 0。如果因为某种原因多減了,就會 revert。
- 沒有 return true:ERC20 的 approve/transfer/transferFrom 都要 return bool,但這個實作完全忽略了。
正確版本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract GoodToken is IERC20 {
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(
string memory _name,
string memory _symbol,
uint8 _decimals,
uint256 _initialSupply
) {
name = _name;
symbol = _symbol;
decimals = _decimals;
totalSupply = _initialSupply * 10 ** uint256(_decimals);
balanceOf[msg.sender] = totalSupply;
}
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool) {
require(
allowance[from][msg.sender] >= amount,
"未授權或額度不足"
);
allowance[from][msg.sender] -= amount;
_transfer(from, to, amount);
return true;
}
function _transfer(
address from,
address to,
uint256 amount
) internal {
require(from != address(0), "from 是零地址");
require(to != address(0), "to 是零地址");
require(balanceOf[from] >= amount, "余額不足");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
}
重要的改進:
- 繼承 IERC20 interface:這確保你的合約符合 ERC20 標準。
- 使用 internal function _transfer:transfer 和 transferFrom 都要做同樣的檢查,用一個 internal function 避免重複代碼。
- require 檢查零地址:發送到零地址的代幣等於燒掉,必須明確拒絕。
- 使用 SafeMath(或 Solidity 0.8+ 的內建檢查):新版 Solidity 自動檢查 overflow,不需要 SafeMath 了。
Ownable 合約:權限控制的正確姿勢
大部分合約都需要一個 owner,可以做一些特殊操作。OpenZeppelin 提供了標准的 Ownable 合約,但理解它的原理很重要。
錯誤的 Ownable 實作
pragma solidity ^0.8.0;
contract BadOwnable {
address public owner;
constructor() {
owner = msg.sender;
}
function setOwner(address newOwner) public {
owner = newOwner; // 任何人都可以改 owner!
}
}
天真地以為 public owner 變數只有自己能改?錯了。任何人都可以呼叫 setOwner,把 owner 改掉。
正確的 Ownable 實作
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
abstract contract Ownable {
address private _owner;
address private _pendingOwner;
event OwnershipTransferred(
address indexed previousOwner,
address indexed newOwner
);
event OwnershipPending(
address indexed currentOwner,
address indexed pendingOwner
);
constructor() {
_owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == _owner, "不是合約擁有者");
_;
}
function owner() public view returns (address) {
return _owner;
}
// 兩步驟轉移:先 request,再 accept
function transferOwnership(address newOwner) public onlyOwner {
require(
newOwner != address(0),
"新 owner 不能是零地址"
);
require(
newOwner != _owner,
"新 owner 不能是現任 owner"
);
_pendingOwner = newOwner;
emit OwnershipPending(_owner, newOwner);
}
function acceptOwnership() public {
require(
msg.sender == _pendingOwner,
"你不是 pending owner"
);
address oldOwner = _owner;
_owner = _pendingOwner;
_pendingOwner = address(0);
emit OwnershipTransferred(oldOwner, _owner);
}
}
為什麼要用兩步驟轉移?
想象一下,如果你的 owner 錢包被駭了。攻擊者可以直接呼叫 transferOwnership 把 owner 改掉。
兩步驟的好處是:即使攻擊者知道你有一個合約,他沒有你錢包的控制權,沒辦法 acceptOwnership。舊 owner 發現被改後,可以馬上接管。
常見的 Gas 優化錯誤
Gas 優化是智能合約開發的重要課題。但很多新手為了省 Gas,犯了更大的錯誤。
錯誤:把所有資料都放 storage
pragma solidity ^0.8.0;
contract GasWaste {
// Storage 變數超級貴
uint256 public totalDeposits;
address[] public depositors;
mapping(address => uint256) public depositAmount;
function deposit() external payable {
// 每次存款都寫 storage
totalDeposits += msg.value;
depositors.push(msg.sender);
depositAmount[msg.sender] += msg.value;
}
}
這段代碼看起來沒問題,但假設有 10000 個人存款。depositors array 會變得超級大,每次遍歷都要燒 Gas。
更好的設計:Events 而不是 Storage
pragma solidity ^0.8.0;
contract GasFriendly {
uint256 public totalDeposits;
mapping(address => uint256) public depositAmount;
event Deposit(address indexed user, uint256 amount);
function deposit() external payable {
require(msg.value > 0, "存款金額必須大於 0");
totalDeposits += msg.value;
depositAmount[msg.sender] += msg.value;
// 透過 event 記錄存款者信息
// Event 不吃 storage,只需要約 8 gas per byte
emit Deposit(msg.sender, msg.value);
}
}
原則:
- Storage 是世界上最貴的記憶體。能不寫,就不寫。
- 大量資料適合存在 events 裡,用 etherscan 的事件日誌查。
- 只在必要時遍歷 storage(比如某個 address 的存款記錄)。
與合約互動的正確方式
錯誤:假設 external call 一定成功
pragma solidity ^0.8.0;
interface IToken {
function transfer(address to, uint256 amount) external returns (bool);
}
contract BadCaller {
function callTransfer(address token, address to, uint256 amount)
external
{
IToken(token).transfer(to, amount);
// 如果 transfer 失敗了怎麼辦?
// 這個 function 會 revert,影響整個交易
}
}
正確:檢查返回值
pragma solidity ^0.8.0;
interface IToken {
function transfer(address to, uint256 amount) external returns (bool);
}
contract GoodCaller {
event TransferResult(bool success, bytes data);
function callTransfer(address token, address to, uint256 amount)
external
{
(bool success, bytes memory data) = IToken(token).call(
abi.encodeWithSignature(
"transfer(address,uint256)",
to,
amount
)
);
if (!success) {
// 處理失敗的情況
// 可以 emit event,或者存入 mapping 供之後查詢
emit TransferResult(false, data);
return;
}
emit TransferResult(true, data);
}
}
或者更推薦:直接用 IERC20 的回傳值
pragma solidity ^0.8.0;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract SafeTokenTransfer {
mapping(address => mapping(address => bool)) public failedTransfers;
event TransferFailed(
address indexed token,
address indexed from,
address indexed to,
uint256 amount
);
function safeTransfer(
address token,
address to,
uint256 amount
) internal {
(bool success, ) = token.call(
abi.encodeWithSelector(
IERC20.transfer.selector,
to,
amount
)
);
if (!success) {
failedTransfers[msg.sender][token] = true;
emit TransferFailed(token, msg.sender, to, amount);
}
}
}
合約升級:Proxy Pattern 入門
Solidity 合約一旦部署就不能改。如果你想修復 bug 或加功能,怎麼辦?
答案是 Proxy Pattern。
簡單的 Proxy 範例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 邏輯合約(可以升級)
contract LogicV1 {
uint256 public value;
function setValue(uint256 _value) external {
value = _value;
}
}
contract LogicV2 {
uint256 public value;
uint256 public secondValue; // 新增的功能
function setValue(uint256 _value) external {
value = _value;
}
function setSecondValue(uint256 _value) external {
secondValue = _value;
}
}
// Proxy 合約(不變)
contract SimpleProxy {
address public implementation;
function upgradeTo(address newImplementation) external {
implementation = newImplementation;
}
fallback() external payable {
address impl = implementation;
require(impl != address(0), "未設定 implementation");
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(
gas(),
impl,
ptr,
calldatasize(),
0,
0
)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
case 1 { return(ptr, size) }
}
}
}
運作原理:
- 用戶呼叫 Proxy 合約
- Proxy 的 fallback function 把呼叫 delegatecall 到 Logic 合約
- Logic 合約在 Proxy 的 storage 上下文執行
- 升級時,只是把 implementation address 換成新的 Logic 合約
警告: 這只是一個簡化的範例。真實世界的 proxy 合約(如 EIP-1967)還需要很多安全檢查。建議直接使用 OpenZeppelin 的 Proxy 合約庫。
測試:怎麼確保你的合約沒問題
使用 Hardhat 撰寫測試
// test/Bank.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("GoodBank", function () {
let bank;
let owner;
let user;
beforeEach(async function () {
[owner, user] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("GoodBank");
bank = await Bank.deploy();
await bank.deployed();
});
it("應該允許存款", async function () {
const depositAmount = ethers.utils.parseEther("1.0");
await bank.deposit({ value: depositAmount });
expect(await bank.balances(owner.address)).to.equal(depositAmount);
});
it("應該允許正確的提款", async function () {
const depositAmount = ethers.utils.parseEther("1.0");
const withdrawAmount = ethers.utils.parseEther("0.5");
await bank.deposit({ value: depositAmount });
await bank.withdraw(withdrawAmount);
expect(await bank.balances(owner.address)).to.equal(
depositAmount.sub(withdrawAmount)
);
});
it("不應該允許超額提款", async function () {
const depositAmount = ethers.utils.parseEther("1.0");
const withdrawAmount = ethers.utils.parseEther("2.0");
await bank.deposit({ value: depositAmount });
await expect(
bank.withdraw(withdrawAmount)
).to.be.revertedWith("余額不足");
});
it("只有 owner 可以呼叫 withdrawAll", async function () {
await expect(
bank.connect(user).withdrawAll()
).to.be.revertedWith("不是合約擁有者");
});
});
執行測試:
npx hardhat test
結語:安全沒有捷徑
智能合約開發沒有捷徑。
我給你的這些範例,只是最基礎的正確做法。真實世界中,還有更多需要注意的地方:
- 閃電貸攻擊
- Price Oracle 操縱
- Front-running
- 變數讀寫順序導致的漏洞
建議你:
- 熟讀 OpenZeppelin 的合約庫,理解每個設計背後的原因
- 使用 Slither 或 Mythril 做靜態和動態分析
- 正式部署前,找專業的審計公司做代碼審計
- 在 testnet 上充分測試,確保邏輯正確
最重要的是:永遠假設你的合約有漏洞。
這種心態會讓你更謹慎,更願意花時間檢查每一行程式碼。
實用工具連結
- OpenZeppelin Contracts:https://docs.openzeppelin.com/contracts/
- Solidity 官方文檔:https://docs.soliditylang.org/
- Hardhat 框架:https://hardhat.org/
- Slither 靜態分析:https://github.com/crytic/slither
- Etherscan 合約驗證:https://etherscan.io/
免責聲明
本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。智能合約開發涉及極高的風險,任何程式碼範例都必須經過充分測試和專業審計後才能用於實際部署。在進行任何區塊鏈相關操作前,請自行研究並諮詢專業人士意見。
相關文章
- 以太坊基礎概念系統性學習路徑:從零開始的完整引導指南 — 本文專為區塊鏈新手讀者設計,提供從零開始學習以太坊的系統性路徑。我們採用「概念先行、代碼驗證」的教學理念,先用直觀的比喻和日常生活案例解釋核心概念,再逐步過渡到技術細節。涵蓋區塊鏈基礎、錢包概念、Gas 機制、智能合約、DeFi 入門、Layer 2 等七大主題模組,每個模組配有學習目標、關鍵術語、概念解釋、延伸資源和自我測驗。
- 以太坊新手入門完整學習路徑 2026:從零開始的系統化區塊鏈修煉手冊 — 本文提供一條經過精心規劃、循序漸進的以太坊學習路徑。將學習內容組織為五大模組:區塊鏈基礎理論與密碼學原語、以太坊核心概念與技術架構、錢包安全與資產管理、智能合約開發基礎、以及生態系統導覽。每個模組均按照「理解概念 → 建立直覺 → 實際操作 → 深化理解」的認知階梯設計,配有量化數據支撐(驗證者數量、Gas 參數、TVL 規模等),幫助讀者在每個階段都獲得足夠的成就感與繼續前進的動力。
- Web2 到 Web3 以太坊遷移完整指南 2026:從傳統金融用戶到加密原生投資者的客製化學習路徑 — 本指南根據不同投資者類型,設計了客製化的 Web2 到 Web3 學習路徑。我們涵蓋保守型、穩健型、技術型和專業型四種投資者的差異化需求,提供從基礎概念到高級策略的完整學習框架。包含錢包選擇、安全管理、DeFi 操作、協議分析等實務內容,以及傳統金融到 DeFi 的完整遷移地圖。
- 以太坊錢包安全實戰手冊:從資產審查到智能合約互動風險檢查 — 本文從實戰角度出發,系統化地介紹以太坊錢包安全的核心知識。涵蓋錢包授權管理、智能合約互動風險檢查、常見攻擊手法與防護策略、以及資產安全的系統化流程。提供完整的自我審計清單和進階安全工具推薦,幫助投資者建立完善的資產安全防護體系。
- 以太坊實務操作指南完整手冊:從交易所提幣到 DeFi 操作的完整流程 — 本文提供以太坊完整實務操作指南,涵蓋交易所提幣到自我保管、質押操作、DeFi 交互、DAO 治理投票等 step-by-step 教學,幫助用戶從理論走向實際操作。我們將詳細說明每個步驟的風險點與注意事項,並提供可實際運用的命令範例與工具推薦。
延伸閱讀與來源
- 以太坊官方新手指南 官方推薦的 ETH 購買與錢包設置指南
- MetaMask 官方文檔 最廣泛使用的錢包設置教學
- Coinbase 學習 合規交易所操作指南
這篇文章對您有幫助嗎?
請告訴我們如何改進:
評論
發表評論
注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。
目前尚無評論,成為第一個發表評論的人吧!