Gas là gì và vì sao phí thay đổi trong EVM

1. Tổng quan

Nếu bạn từng gửi transaction trên Ethereum và thấy phí lúc rẻ lúc đắt gấp 10 lần, bạn đang chạm vào khái niệm gas. Bài này giải thích gas là gì, cách EVM tính phí trước và sau EIP-1559, vì sao baseFee thay đổi theo từng block. Đọc xong, bạn sẽ tự tính được phí giao dịch bằng code và biết đặt maxFeePerGas hợp lý để tránh transaction bị treo hoặc trả dư tiền.

2. Gas là gì

Gas là đơn vị đo công việc tính toán mà EVM (Ethereum Virtual Machine) phải thực hiện để chạy một transaction. Mỗi opcode (lệnh máy ảo) có một chi phí gas cố định — cộng số nguyên (ADD) rẻ, ghi dữ liệu vào storage (SSTORE) đắt gấp hàng trăm lần.

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

contract Counter {
    uint256 public value; // slot storage

    function increment() public {
        value = value + 1; // ADD (~3 gas) + SSTORE (~5000-20000 gas)
    }
}

Dòng value = value + 1 thực chất là hai bước: tính toán số học (rẻ) và ghi lại vào storage (đắt). Đây là lý do tối ưu smart contract thường tập trung vào giảm số lần đọc/ghi storage, không phải giảm phép tính.

Lưu ý: Gas không phải là tiền. Gas là “lượng công việc”. Phí thật = gas dùng × giá mỗi gas (tính bằng ETH).

3. Gas limit, gas price và gas used

Ba khái niệm hay bị nhầm:

  • Gas limit: số gas tối đa bạn cho phép transaction tiêu. Đặt quá thấp → transaction revert với lỗi out of gas.
  • Gas used: số gas thực tế đã tiêu, luôn ≤ gas limit.
  • Gas price: giá mỗi đơn vị gas, tính bằng gwei (1 gwei = 10⁻⁹ ETH).
Phí giao dịch (trước EIP-1559) = gasUsed × gasPrice

Ví dụ transfer ETH đơn giản tốn 21.000 gas, nếu gasPrice = 30 gwei:

Phí = 21.000 × 30 gwei = 630.000 gwei = 0.00063 ETH

4. Cơ chế cũ: legacy gas price

Trước EIP-1559 (trước fork London, 2021), người dùng tự đặt gasPrice. Giá cao thì miner ưu tiên đưa transaction vào block trước. Cơ chế này giống đấu giá mù: bạn không biết đối thủ trả bao nhiêu, dễ trả quá cao hoặc quá thấp rồi phải chờ.

5. EIP-1559: base fee + priority fee

EIP-1559 tách phí thành hai phần:

  • baseFee: do network tự tính dựa trên độ đầy của block trước, bị burn (đốt, không vào tay validator).
  • priorityFee (tip): bạn trả thêm cho validator để được ưu tiên.

Người dùng đặt hai giá trị:

  • maxFeePerGas: giá tối đa bạn chấp nhận trả mỗi gas.
  • maxPriorityFeePerGas: tip tối đa bạn chấp nhận trả.

5.1. Công thức tính phí thực tế

effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
phí thực tế        = gasUsed × effectiveGasPrice

5.2. Tính fee bằng Java

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
package dev.nonla.evm;

import java.math.BigInteger;

public class FeeCalculator {

    public BigInteger effectiveGasPrice(
            BigInteger baseFee,
            BigInteger maxPriorityFeePerGas,
            BigInteger maxFeePerGas) {

        BigInteger desired = baseFee.add(maxPriorityFeePerGas);
        return desired.min(maxFeePerGas); // không bao giờ vượt maxFeePerGas
    }

    public BigInteger totalFee(BigInteger gasUsed, BigInteger effectiveGasPrice) {
        return gasUsed.multiply(effectiveGasPrice);
    }
}
  • effectiveGasPrice lấy giá trị nhỏ hơn giữa (baseFee + tip mong muốn) và mức trần maxFeePerGas bạn đặt.
  • totalFee nhân với gas đã dùng thực tế để ra số ETH phải trả.
package dev.nonla.evm;

import org.junit.jupiter.api.Test;
import java.math.BigInteger;

import static org.assertj.core.api.Assertions.assertThat;

class FeeCalculatorTest {

    private final FeeCalculator calculator = new FeeCalculator();

    @Test
    void khiBaseFeeCongTipNhoHonMax_thiDungTipDayDu() {
        BigInteger baseFee = BigInteger.valueOf(20); // gwei
        BigInteger tip = BigInteger.valueOf(2);
        BigInteger maxFee = BigInteger.valueOf(50);

        BigInteger price = calculator.effectiveGasPrice(baseFee, tip, maxFee);

        assertThat(price).isEqualTo(BigInteger.valueOf(22));
    }

    @Test
    void khiBaseFeeTangVuotMax_thiBiGhimOMaxFeePerGas() {
        BigInteger baseFee = BigInteger.valueOf(60); // tăng mạnh
        BigInteger tip = BigInteger.valueOf(2);
        BigInteger maxFee = BigInteger.valueOf(50);

        BigInteger price = calculator.effectiveGasPrice(baseFee, tip, maxFee);

        assertThat(price).isEqualTo(maxFee); // ghim ở 50, không trả quá
    }
}

Test thứ hai minh họa đúng lý do transaction của bạn bị treo (pending) khi network tắc: baseFee tăng vượt maxFeePerGas bạn đặt, validator không còn động lực nhận vì tip thực nhận quá thấp.

6. Vì sao base fee thay đổi theo từng block

baseFee không cố định — nó tự điều chỉnh theo độ đầy block trước, dựa trên công thức xấp xỉ:

baseFee_tiếp = baseFee_hiện_tại × (1 + 1/8 × (gasUsed - gasTarget) / gasTarget)

Trong đó gasTarget là 50% gasLimit của block (mỗi block Ethereum có gas limit khoảng 30 triệu, target khoảng 15 triệu).

  • Block đầy hơn target → baseFee tăng tối đa 12.5% mỗi block.
  • Block trống hơn target → baseFee giảm tối đa 12.5% mỗi block.
package dev.nonla.evm;

import java.math.BigInteger;

public class BaseFeeAdjuster {

    private static final BigInteger EIGHT = BigInteger.valueOf(8);

    public BigInteger nextBaseFee(BigInteger currentBaseFee, long gasUsed, long gasTarget) {
        long delta = gasUsed - gasTarget;
        BigInteger change = currentBaseFee
                .multiply(BigInteger.valueOf(delta))
                .divide(BigInteger.valueOf(gasTarget))
                .divide(EIGHT);

        BigInteger next = currentBaseFee.add(change);
        // baseFee không bao giờ âm
        return next.max(BigInteger.ONE);
    }
}
@Test
void khiBlockDayHonTarget_thiBaseFeeTang() {
    BaseFeeAdjuster adjuster = new BaseFeeAdjuster();
    BigInteger current = BigInteger.valueOf(100); // gwei

    BigInteger next = adjuster.nextBaseFee(current, 30_000_000L, 15_000_000L);

    // gasUsed = 2x target -> tăng gần 12.5%
    assertThat(next).isGreaterThan(current);
}

Cơ chế tự điều chỉnh này giải thích tại sao bạn thấy baseFee nhảy liên tục mỗi ~12 giây (thời gian một block): network đang tự cân bằng cung-cầu không gian block, không phải miner/validator tự đặt giá tùy ý.

7. Quan sát base fee và gas price qua JSON-RPC

Bạn có thể lấy baseFeePerGas trực tiếp từ node Ethereum:

curl -s -X POST https://rpc.ankr.com/eth \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":1}' \
  | jq '.result.baseFeePerGas'
"0x9502f9000"

Giá trị trả về là hex (wei), bạn chuyển sang gwei bằng cách chia 10⁹.

8. Ước lượng gas trước khi gửi transaction với Web3j

Thay vì đoán gasLimit, hãy để node tính trước bằng eth_estimateGas — tránh đặt quá thấp (revert) hoặc quá cao (khóa dư ETH tạm thời).

<dependency>
    <groupId>org.web3j</groupId>
    <artifactId>core</artifactId>
    <version>4.12.2</version>
</dependency>
package dev.nonla.evm;

import org.web3j.protocol.Web3j;
import org.web3j.protocol.core.methods.response.EthEstimateGas;
import org.web3j.protocol.core.methods.request.Transaction;
import java.math.BigInteger;

public class GasEstimator {

    private final Web3j web3j;

    public GasEstimator(Web3j web3j) {
        this.web3j = web3j;
    }

    public BigInteger estimate(String from, String to, String data) throws Exception {
        Transaction tx = Transaction.createFunctionCallTransaction(
                from, null, null, null, to, data);

        EthEstimateGas response = web3j.ethEstimateGas(tx).send();
        if (response.hasError()) {
            throw new IllegalStateException("Estimate thất bại: " + response.getError().getMessage());
        }
        return response.getAmountUsed();
    }
}
  • ethEstimateGas mô phỏng transaction trên state hiện tại và trả về gas cần dùng.
  • Thực tế nên cộng thêm buffer 10–20% vào kết quả này trước khi đặt gasLimit, vì state có thể thay đổi giữa lúc estimate và lúc transaction thật sự chạy.

9. Lỗi thường gặp

  • Đặt gasLimit sát đúng số estimate: nếu storage layout thay đổi nhẹ (ví dụ slot từ 0 → non-zero tốn gas khác), transaction dễ revert vì out of gas. Luôn cộng buffer.
  • Đặt maxFeePerGas quá thấp trong lúc network tắc: transaction nằm trong mempool vô thời hạn, không timeout tự động — bạn phải tự hủy bằng cách gửi transaction cùng nonce với fee cao hơn (replace-by-fee).
  • Nhầm gasPrice (legacy) với maxFeePerGas (EIP-1559): gửi transaction kiểu cũ trên mạng đã hỗ trợ 1559 vẫn hoạt động, nhưng bạn mất quyền kiểm soát tip riêng — ví có thể trả dư so với baseFee thực tế.
  • Không tính priorityFee quá thấp: nếu tip gần 0 trong lúc block đầy, validator không có động lực đưa transaction vào block sớm dù maxFeePerGas cao.

Lưu ý: Đừng hard-code gasPrice cố định trong code production. Luôn gọi eth_feeHistory hoặc eth_gasPrice để lấy giá trị thị trường theo thời điểm, rồi áp công thức ở mục 5.

10. Kết luận

Gas là đơn vị đo công việc tính toán trong EVM; phí thực tế = gas dùng × giá mỗi gas. Với EIP-1559, giá đó tách thành baseFee tự điều chỉnh theo độ đầy block và priorityFee do bạn chọn để ưu tiên. Hiểu rõ công thức này giúp bạn đặt gasLimit/maxFeePerGas hợp lý, tránh transaction bị treo hoặc trả phí oan — và nếu cần, bạn hoàn toàn có thể tự viết logic tính fee bằng Java như ví dụ trên để tích hợp vào backend theo dõi ví hoặc dashboard giao dịch.

evmblockchainethereumgaseip-1559