Solidity cơ bản: contract, state và function

1. Tổng quan

Solidity là ngôn ngữ chính để viết smart contract trên Ethereum và các chain tương thích EVM. Bài này giúp bạn nắm ba khối xây dựng cơ bản nhất: contract, state variable và function — thông qua một hợp đồng Wallet đơn giản. Đọc xong, bạn viết được contract có lưu trạng thái, kiểm soát quyền truy cập bằng modifier, và viết test đầu tiên bằng Hardhat.

2. Contract là gì

Trong Solidity, contract giống như class trong Java — nó định nghĩa state (dữ liệu) và behavior (function) sẽ được deploy lên blockchain thành một địa chỉ độc lập.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Wallet {
    // state variables và function sẽ nằm ở đây
}
  • Dòng // SPDX-License-Identifier khai báo license, trình biên dịch sẽ cảnh báo nếu thiếu.
  • pragma solidity ^0.8.24 khóa phiên bản compiler từ 0.8.24 trở lên (nhưng dưới 0.9.0).
  • Mỗi contract khi deploy sẽ có một địa chỉ (address) riêng, giống như một “object” sống vĩnh viễn trên chain.

Lưu ý: Không bao giờ dùng pragma dạng >=0.8.0 <0.9.0 quá lỏng cho production — compiler mới có thể thay đổi hành vi mặc định (ví dụ overflow check).

3. State variable — dữ liệu được lưu on-chain

State variable là biến được ghi thẳng vào storage của blockchain, tồn tại giữa các lần gọi hàm và tốn gas khi ghi.

contract Wallet {
    address public owner;
    uint256 public balance;
    mapping(address => uint256) public allowance;

    constructor() {
        owner = msg.sender;
    }
}
  • address public owner: lưu địa chỉ chủ hợp đồng. Từ khóa public tự sinh getter owner() miễn phí.
  • uint256 public balance: số dư, kiểu số nguyên không dấu 256-bit — kiểu số phổ biến nhất trong Solidity.
  • mapping(address => uint256): giống HashMap<Address, BigInteger> nhưng không duyệt được key, chỉ tra cứu theo key.
  • constructor() chạy đúng một lần lúc deploy, msg.sender là địa chỉ gọi giao dịch deploy.

3.1. Phân biệt storage, memory, calldata

function updateAllowance(address spender, uint256 amount) external {
    // amount nằm ở calldata (read-only, rẻ gas)
    allowance[spender] = amount; // ghi vào storage
}
  • storage: dữ liệu vĩnh viễn trên chain (state variable).
  • memory: dữ liệu tạm trong lúc thực thi function, mất sau khi function kết thúc.
  • calldata: giống memory nhưng read-only, dùng cho tham số của external function — tiết kiệm gas hơn memory.

Lưu ý: Quên khai báo memory/calldata cho tham số kiểu array, struct, string sẽ khiến compiler báo lỗi — khác hẳn Java, nơi bạn không cần quan tâm vùng nhớ.

4. Function và visibility

Function trong Solidity bắt buộc khai báo visibility rõ ràng, không có mặc định “package-private” như Java.

contract Wallet {
    address public owner;
    uint256 public balance;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        balance += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(msg.sender == owner, "Wallet: not owner");
        require(amount <= balance, "Wallet: insufficient balance");

        balance -= amount;
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Wallet: transfer failed");
    }

    function getBalance() external view returns (uint256) {
        return balance;
    }
}
  • external: chỉ gọi được từ bên ngoài contract (ví dụ ví MetaMask, contract khác) — rẻ gas hơn public khi tham số là array/string lớn.
  • payable: cho phép function nhận ETH kèm giao dịch; msg.value là số wei gửi kèm.
  • view: cam kết không thay đổi state, không tốn gas khi gọi off-chain (qua eth_call).
  • require(condition, "message"): kiểm tra điều kiện, revert toàn bộ giao dịch kèm thông báo nếu sai — tương tự ném IllegalArgumentException nhưng rollback cả state lẫn gas đã dùng (trừ phần gas cho việc kiểm tra).
  • owner.call{value: amount}(""): cách gửi ETH được khuyến nghị thay vì transfer()/send() vì không giới hạn cứng 2300 gas.

Bốn mức visibility:

Visibility Gọi từ bên trong contract Gọi từ contract con Gọi từ bên ngoài
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌ (phải dùng this.foo()) ❌ ✅

5. Modifier — tái sử dụng điều kiện kiểm tra

Lặp lại require(msg.sender == owner, ...) ở nhiều function là dấu hiệu nên tách thành modifier.

contract Wallet {
    address public owner;
    uint256 public balance;

    modifier onlyOwner() {
        require(msg.sender == owner, "Wallet: not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= balance, "Wallet: insufficient balance");
        balance -= amount;
        payable(owner).transfer(amount);
    }
}
  • modifier onlyOwner() định nghĩa một khối kiểm tra; _; là vị trí thân function gốc sẽ được chèn vào.
  • Gắn onlyOwner sau khai báo withdraw giúp code đọc như “chỉ chủ ví mới được rút tiền” — rõ ràng hơn so với require lặp lại ở từng hàm.
  • Modifier có thể nhận tham số, ví dụ modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }.

6. Event — ghi log cho off-chain

Contract không thể trả dữ liệu lịch sử trực tiếp cho frontend; event dùng để ghi log mà dApp có thể lắng nghe.

contract Wallet {
    event Deposited(address indexed from, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);

    uint256 public balance;

    function deposit() external payable {
        balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
}
  • event Deposited(...) khai báo cấu trúc log, tối đa 3 tham số có indexed để tiện filter.
  • emit Deposited(...) ghi log vào transaction receipt, chi phí gas rẻ hơn nhiều so với lưu vào storage.
  • Frontend dùng thư viện như ethers.js lắng nghe contract.on("Deposited", ...) để cập nhật UI real-time.

7. Lỗi thường gặp khi mới học Solidity

  • Nhầm transfer/send/call khi gửi ETH: transfer giới hạn 2300 gas, dễ fail với contract nhận có logic phức tạp (proxy, multisig). Dùng call kèm require kiểm tra kết quả như ví dụ ở mục 4.
  • Quên require kiểm tra input: thiếu validate khiến contract bị khai thác (ví dụ rút âm, overflow — dù từ Solidity 0.8 đã tự revert khi overflow, nhưng logic nghiệp vụ vẫn cần validate riêng).
  • So sánh external và public sai ngữ cảnh: dùng public cho function chỉ gọi từ ngoài làm tốn gas hơn khi tham số là mảng/string, vì Solidity phải copy dữ liệu vào memory thay vì đọc thẳng từ calldata.
  • Không khai báo view/pure khi có thể: khiến các tool như block explorer, ví không nhận diện được hàm chỉ đọc, người dùng tưởng nhầm phải trả gas.

Khi nào KHÔNG nên để function public: nếu function không bao giờ được gọi từ contract khác hay kế thừa, hãy dùng external để tiết kiệm gas và giới hạn bề mặt tấn công (attack surface).

8. Viết test với Hardhat

Thêm dependency cho project Hardhat (Node.js):

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

Test hành vi withdraw chỉ cho phép owner:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Wallet", function () {
  it("chỉ owner mới được rút tiền", async function () {
    const [owner, stranger] = await ethers.getSigners();
    const Wallet = await ethers.getContractFactory("Wallet");
    const wallet = await Wallet.deploy();

    await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });

    await expect(
      wallet.connect(stranger).withdraw(ethers.parseEther("1"))
    ).to.be.revertedWith("Wallet: not owner");

    await expect(
      wallet.connect(owner).withdraw(ethers.parseEther("1"))
    ).to.changeEtherBalance(owner, ethers.parseEther("1"));
  });
});
  • ethers.getContractFactory("Wallet") biên dịch và nạp artifact của contract.
  • wallet.connect(stranger) giả lập giao dịch gửi từ một địa chỉ khác owner.
  • revertedWith("...") assert rằng transaction bị revert đúng message từ require.
  • changeEtherBalance kiểm tra số dư ETH của owner tăng đúng 1 ETH sau khi rút — cách test phổ biến thay vì chỉ check return value.

Chạy test:

npx hardhat test
Wallet
  ✔ chỉ owner mới được rút tiền (120ms)

1 passing (120ms)

9. Kết luận

Bạn vừa đi qua bốn khái niệm nền tảng của Solidity: contract làm đơn vị triển khai, state variable lưu dữ liệu on-chain với ba vùng nhớ storage/memory/calldata, function với visibility và modifier kiểm soát quyền truy cập, và event để giao tiếp với off-chain. Những khái niệm này là nền để bạn tiến tới các chủ đề nâng cao hơn như inheritance, interface, hay chuẩn ERC-20/ERC-721 — tất cả đều xây trên đúng bốn khối này.

solidityblockchainsmart-contractethereumweb3