智能合約實戰程式碼範例:常見錯誤與解決方案

本文從實務角度出發,提供可直接複製使用的 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 {}
}

為什麼這樣是對的?

  1. Checks-Effects-Interactions:先把余額減掉,再轉帳。這樣即使攻擊者想重入,余額已經不夠了。
  1. 使用 call 而不是 transfer:transfer 只給 2300 gas,call 可以調整 gas 用量,更靈活。
  1. 檢查返回值:轉帳可能失敗,你必須檢查 success,否則使用者余額扣了但沒收到錢。
  1. 使用 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;
    }
}

問題在哪裡?

  1. 沒有宣告 decimals:ERC20 標準規定要有一個 decimals function,預設值是 18。
  1. transferFrom 的減法可能 overflow:如果 allowance 剛好等於 amount,減完變成 0。如果因为某种原因多減了,就會 revert。
  1. 沒有 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);
    }
}

重要的改進:

  1. 繼承 IERC20 interface:這確保你的合約符合 ERC20 標準。
  1. 使用 internal function _transfer:transfer 和 transferFrom 都要做同樣的檢查,用一個 internal function 避免重複代碼。
  1. require 檢查零地址:發送到零地址的代幣等於燒掉,必須明確拒絕。
  1. 使用 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);
    }
}

原則:

與合約互動的正確方式

錯誤:假設 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) }
        }
    }
}

運作原理:

  1. 用戶呼叫 Proxy 合約
  2. Proxy 的 fallback function 把呼叫 delegatecall 到 Logic 合約
  3. Logic 合約在 Proxy 的 storage 上下文執行
  4. 升級時,只是把 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

結語:安全沒有捷徑

智能合約開發沒有捷徑。

我給你的這些範例,只是最基礎的正確做法。真實世界中,還有更多需要注意的地方:

建議你:

  1. 熟讀 OpenZeppelin 的合約庫,理解每個設計背後的原因
  2. 使用 Slither 或 Mythril 做靜態和動態分析
  3. 正式部署前,找專業的審計公司做代碼審計
  4. 在 testnet 上充分測試,確保邏輯正確

最重要的是:永遠假設你的合約有漏洞。

這種心態會讓你更謹慎,更願意花時間檢查每一行程式碼。


實用工具連結


免責聲明

本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。智能合約開發涉及極高的風險,任何程式碼範例都必須經過充分測試和專業審計後才能用於實際部署。在進行任何區塊鏈相關操作前,請自行研究並諮詢專業人士意見。

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!