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-Identifierkhai báo license, trình biên dịch sẽ cảnh báo nếu thiếu. pragma solidity ^0.8.24khó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.0quá 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óapublictự sinh getterowner()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ốngHashMap<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.senderlà đị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ốngmemorynhưng read-only, dùng cho tham số củaexternalfunction — tiết kiệm gas hơnmemory.
Lưu ý: Quên khai báo
memory/calldatacho tham số kiểuarray,struct,stringsẽ 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ơnpublickhi tham số là array/string lớn.payable: cho phép function nhận ETH kèm giao dịch;msg.valuelà 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 (quaeth_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émIllegalArgumentExceptionnhư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
onlyOwnersau khai báowithdrawgiúp code đọc như “chỉ chủ ví mới được rút tiền” — rõ ràng hơn so vớirequirelặ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/callkhi gửi ETH:transfergiới hạn 2300 gas, dễ fail với contract nhận có logic phức tạp (proxy, multisig). Dùngcallkèmrequirekiểm tra kết quả như ví dụ ở mục 4. - Quên
requirekiể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
externalvàpublicsai ngữ cảnh: dùngpubliccho 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àomemorythay vì đọc thẳng từcalldata. - Không khai báo
view/purekhi 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ùngexternalđể 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.changeEtherBalancekiể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.