Chuyển đến nội dung chính

Cho phép người dùng không cần Gas của bạn nắm giữ token và gọi hợp đồng

không cần gas
erc-20
trừu tượng hóa tài khoản
Trung cấp
Ori Pomerantz
1 tháng 4, 2026
22 phút đọc

Giới thiệu

Một bài viết trước đã thảo luận về việc sử dụng quyền truy cập không cần Gas vào ứng dụng của riêng bạn bằng cách sử dụng chữ ký EIP-712, nhưng nó bị giới hạn ở các hợp đồng thông minh của riêng bạn. Sử dụng trừu tượng hóa tài khoản, chúng ta có thể tạo các ví hợp đồng thông minh chấp nhận hai loại giao dịch và chuyển tiếp chúng đến một đích được yêu cầu:

  • Các giao dịch được gửi bởi một EOA cụ thể (yêu cầu EOA đó phải có ETH)
  • Các giao dịch được gửi từ bất kỳ đâu, nhưng được ký bởi cùng một EOA.

Bằng cách này, chúng ta có thể cung cấp một cách không cần Gas để một Tài khoản nắm giữ tài sản (token, v.v.) và thực hiện tất cả các chức năng mà một EOA có Gas có thể làm.

Tại sao chúng ta không thể chỉ chuyển tiếp yêu cầu?

Trong ERC-20 và các tiêu chuẩn liên quan, chủ sở hữu Tài khoản là msg.sender (opens in a new tab), Địa chỉ đã gọi hợp đồng token, không nhất thiết phải là người khởi tạo giao dịch, tx.origin (opens in a new tab). Điều này được yêu cầu vì lý do bảo mật (opens in a new tab). Điều này có nghĩa là nếu chúng ta chuyển tiếp các yêu cầu chuyển token, chúng sẽ cố gắng chuyển token từ Địa chỉ của người chuyển tiếp thay vì Địa chỉ do người dùng kiểm soát.

Có một giải pháp cho phép bạn sử dụng Địa chỉ EOA thông qua EIP-7702 (opens in a new tab), nhưng nó yêu cầu việc ký một sự ủy quyền có khả năng gây nguy hiểm, vì vậy bạn chỉ có thể sử dụng nó để ủy quyền cho một hợp đồng thông minh mà nhà cung cấp Ví chấp thuận. Đối với hướng dẫn này, tôi thích phương pháp đơn giản hơn nhiều là tạo một hợp đồng thông minh làm proxy (đại diện) cho người dùng.

Xem nó hoạt động

  1. Đảm bảo bạn có cả Node (opens in a new tab)Foundry (opens in a new tab).

  2. Sao chép (clone) ứng dụng và cài đặt phần mềm cần thiết.

    git clone https://github.com/qbzzt/260315-gasless-tokens.git
    cd 260315-gasless-tokens
    forge build
    cd server
    npm install
    
  3. Chỉnh sửa .env để đặt SEPOLIA_PRIVATE_KEY thành một Ví có ETH trên Sepolia. Nếu bạn cần Sepolia ETH, hãy sử dụng một vòi để lấy nó. Lý tưởng nhất là khóa riêng tư này nên khác với khóa bạn có trong Ví trình duyệt của mình.

  4. Khởi động máy chủ.

    npm run dev
    
  5. Duyệt đến ứng dụng tại URL http://localhost:5173 (opens in a new tab).

  6. Nhấp vào Connect with Injected để kết nối với một Ví. Chấp thuận trong Ví và chấp thuận việc chuyển sang Sepolia nếu cần.

  7. Cuộn xuống và nhấp vào Deploy UserProxy (slow process).

  8. Bạn có thể thấy khi proxy người dùng được triển khai vì có một Địa chỉ bên cạnh UserProxy access. Nếu bạn đã đợi 24 giây (2 khối) mà điều đó vẫn chưa xảy ra, có thể có vấn đề với việc phát hiện các thay đổi.

    Nếu đúng như vậy, hãy truy cập trình khám phá khối Sepolia (opens in a new tab) và nhập mã băm giao dịch triển khai mà bạn thấy trong đầu ra của máy chủ tại npm run dev. Nhấp vào hợp đồng đã tạo để xem Địa chỉ của nó, sau đó sao chép nó. Dán Địa chỉ vào trường Or enter existing proxy address, sau đó nhấp vào Set proxy address.

  9. Nhấp vào Request more tokens for proxy để gửi một lệnh gọi đến hàm faucet (opens in a new tab) của hợp đồng ERC-20 để nhận token. Xác nhận chữ ký trong Ví. Tất nhiên, các token sẽ đến Địa chỉ của proxy, chứ không phải của người dùng.

  10. Cuộn xuống và nhấp vào liên kết dưới Last transaction:. Thao tác này sẽ mở trình duyệt để hiển thị cho bạn giao dịch faucet.

  11. Trong phần amount to transfer, hãy nhập một số từ một đến một nghìn. Nhấp vào Transfer để chuyển token đến Địa chỉ của riêng bạn. Trước khi bạn nhấp vào Xác nhận cho yêu cầu, hãy xem rằng dữ liệu đang được ký là không rõ ràng. Người dùng sẽ gặp khó khăn trong việc hiểu những gì họ đang ký. Hãy nhớ rằng chúng ta sẽ thảo luận về nó bên dưới.

  12. Sau khi giao dịch được xác nhận, hãy đợi để xem sự thay đổi trong cả your balanceproxy balance. Lưu ý rằng điều này cũng sẽ mất một chút thời gian, vì Sepolia có thời gian tạo khối là 12 giây.

Cách thức hoạt động

Để có trải nghiệm không cần Gas, chúng ta cần một giao diện người dùng cho người dùng, một máy chủ để định tuyến các thông điệp từ giao diện người dùng đến Chuỗi và một hợp đồng thông minh để nhận và xác minh chúng.

Hợp đồng thông minh của ví

Đây là hợp đồng thông minh (opens in a new tab). Mục đích của nó là làm bất cứ điều gì chủ sở hữu thực sự yêu cầu, bất kể kênh nào được sử dụng để yêu cầu nó và bỏ qua mọi thứ khác. Để làm điều này, các hàm của nó nhận một Địa chỉ đích để gọi và dữ liệu để sử dụng để gọi nó.

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

contract UserProxy {
    address immutable OWNER;
    uint public nonce = 0;

Danh tính của chủ sở hữu và một nonce (opens in a new tab) để ngăn các thông điệp bị lặp lại. Vì nonce là một biến public, trình biên dịch Solidity cũng tạo ra một hàm view, nonce() (opens in a new tab), cho phép mã ngoài chuỗi đọc giá trị của nó.

    bytes32 private constant SIGNED_ACCESS_TYPEHASH =
        keccak256("SignedAccess(address target,bytes data,uint256 nonce)");

    bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
        keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");

    bytes32 immutable DOMAIN_SEPARATOR;

Thông tin cần thiết để xác minh chữ ký EIP-712 (opens in a new tab).

    constructor(address owner_) {
        OWNER = owner_;

Một UserProxy được gắn với một Địa chỉ chủ sở hữu duy nhất. Điều này là cần thiết vì nó có thể sở hữu tài sản (token ERC-20, NFT, v.v.). Chúng ta không muốn trộn lẫn tài sản thuộc về các chủ sở hữu khác nhau.

Bộ phân tách miền (domain separator) (opens in a new tab). Nó không thể được tính toán tại thời điểm biên dịch, vì nó phụ thuộc vào ID Chuỗi và Địa chỉ hợp đồng. Điều này khiến cho một UserProxy không thể bị đánh lừa bởi một thông điệp được chuẩn bị cho một UserProxy khác.

    event CallResult(address target, bytes returnData);

Ghi Nhật ký kết quả của một lệnh gọi.

    function directAccess(address target, bytes calldata data)
            external returns (bytes memory) {

Hàm này có thể được gọi trực tiếp bởi chủ sở hữu. Nếu không có bộ chuyển tiếp nào khả dụng, chủ sở hữu vẫn có thể truy cập trực tiếp vào tài sản trên Chuỗi khối (nếu người dùng có ETH).

        require(msg.sender == OWNER, "Only owner can call");
        (bool success, bytes memory returnData) = target.call(data);
        require(success, "Call failed");

        emit CallResult(target, returnData);

        return returnData;
    }

Nếu chúng ta được gọi trực tiếp bởi chủ sở hữu, hãy gọi đích đến với dữ liệu lệnh gọi được cung cấp.

    function signedAccess(
        address target,
        bytes calldata data,
        uint8 v,
        bytes32 r,
        bytes32 s)

Đây là hàm chính của UserProxy. Nó nhận targetdata, cũng như một chữ ký.

Bản tóm tắt (digest) cũng bao gồm nonce, nhưng chúng ta không cần nhận nó từ giao dịch; chúng ta đã biết giá trị đúng. Một chữ ký với nonce sai sẽ bị từ chối.


    // Khôi phục người ký
    address signer = ecrecover(digest, v, r, s);
    require(signer == OWNER, "Signature invalid or not by owner");

Nếu chữ ký không hợp lệ, ecrecover thường sẽ trả về một Địa chỉ khác và nó sẽ không được chấp nhận.

    (bool success, bytes memory returnData) = target.call(data);
    require(success, "Call failed");

Gọi hợp đồng mà người dùng đã yêu cầu chúng ta gọi và hoàn nguyên nếu không thành công.

    emit CallResult(target, returnData);

    nonce++; // Tăng nonce để ngăn chặn phát lại

    return returnData;
}

Nếu thành công, phát ra một sự kiện Nhật ký và tăng nonce.

Đây là các biến thể gần như giống hệt nhau cho phép bạn cũng chuyển ETH ra khỏi hợp đồng.

Bộ chuyển tiếp (relayer)

Bộ chuyển tiếp là một thành phần máy chủ. Nó được viết bằng JavaScript; bạn có thể xem mã nguồn tại đây (opens in a new tab).

import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'

Các thư viện chúng ta cần. Đây là một máy chủ Express (opens in a new tab), sử dụng Vite (opens in a new tab) để phục vụ mã giao diện người dùng. Chúng ta sử dụng Viem (opens in a new tab) để giao tiếp với Chuỗi khối và dotenv (opens in a new tab) để đọc khóa riêng tư cho Địa chỉ gửi giao dịch.

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

Đây là một cách đơn giản để đọc UserProxy đã biên dịch. Chúng ta cần ABI để có thể gọi UserProxy và mã đã biên dịch để có thể triển khai nó cho người dùng.

dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)

Đọc tệp .env, trích xuất Địa chỉ và in nó ra bảng điều khiển (console).

Các client Viem giao tiếp với Chuỗi khối.

const start = async () => {
  const app = express()

Chạy một máy chủ Express.

  app.use(express.json())

Yêu cầu Express đọc phần thân yêu cầu (request body) và nếu nó là JSON thì phân tích cú pháp nó.

  app.post("/server/deploy", async (req, res) => {

Đây là mã xử lý các yêu cầu triển khai proxy. Lưu ý rằng chúng ta dễ bị tấn công từ chối dịch vụ (denial-of-service) (opens in a new tab) ở đây vì kẻ tấn công có thể spam chúng ta bằng các yêu cầu triển khai proxy cho đến khi ETH của chúng ta cạn kiệt. Trên một hệ thống sản xuất, chúng ta có thể sẽ yêu cầu rằng yêu cầu triển khai proxy phải được ký và người ký phải là một khách hàng hiện tại.

    try {
      const ownerAddress = req.body.ownerAddress

Lấy Địa chỉ của chủ sở hữu từ yêu cầu.

Triển khai hợp đồng (opens in a new tab)đợi cho đến khi nó được triển khai (opens in a new tab).

      res.json({ contractAddress: receipt.contractAddress })

Nếu mọi thứ đều ổn, trả về Địa chỉ proxy cho giao diện người dùng.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

Nếu có vấn đề, hãy báo cáo nó.

  app.post("/server/message", async (req, res) => {

Đây là mã xử lý các thông điệp của người dùng cho hợp đồng UserProxy. Đây là một điểm khác dễ bị tấn công từ chối dịch vụ.

Lấy dữ liệu yêu cầu và sử dụng nó để gọi signedAccess trên proxy.

      console.log("Message transaction hash:", txHash)

      res.json({ txHash })

Báo cáo lại mã băm giao dịch. Điều này cho phép UI hiển thị một URL để người dùng kiểm tra giao dịch.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

Một lần nữa, nếu có vấn đề, hãy báo cáo nó.

Đối với mọi thứ khác, hãy sử dụng Vite, công cụ xử lý việc phục vụ giao diện người dùng cho chúng ta.

Giao diện người dùng

Đây là mã giao diện người dùng (opens in a new tab). Hầu hết mã gần như giống hệt với mã được ghi lại trong bài viết này, ngoại trừ Token.jsx (opens in a new tab).

Các phần của Token.jsx (opens in a new tab) tương tự như Greeter.jsx (opens in a new tab) trong bài viết này. Dưới đây là các phần mới.

import {
   encodeFunctionData
       } from 'viem'

Hàm này (opens in a new tab) tạo dữ liệu lệnh gọi cho một lệnh gọi hàm EVM. Điều này là cần thiết để người dùng có thể ký dữ liệu lệnh gọi.

import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

UserProxy, đã được giải thích ở trên.

import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

Hợp đồng này (opens in a new tab) chủ yếu là một hợp đồng ERC-20 bình thường, với việc bổ sung một hàm quan trọng, faucet(). Hàm này cấp token cho bất kỳ ai yêu cầu chúng cho mục đích thử nghiệm.

const erc20Addrs = {
  // Sepolia
    11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}

Địa chỉ cho FaucetToken.

const Address = ({ address }) => {
   if (!address) return null
   return (
      <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
   )
}

Thành phần này xuất ra một Địa chỉ với một liên kết đến hợp đồng trên một trình khám phá khối.

const Token = () => {
    ...

Đây là thành phần chính thực hiện hầu hết công việc.

  const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

Số dư token của Địa chỉ người dùng.

  const [ proxyAddr, setProxyAddr ] = useState(null)

Địa chỉ của một proxy thuộc sở hữu của người dùng.

  const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

Số dư token của proxy.

  const [ newProxyAddr, setNewProxyAddr ] = useState("")

Trường này được sử dụng khi người dùng thiết lập Địa chỉ proxy theo cách thủ công. Việc có khả năng thiết lập Địa chỉ proxy theo cách thủ công cho phép người dùng sử dụng một proxy hiện có thay vì triển khai một proxy mới mỗi lần (và mất tất cả các token thuộc sở hữu của proxy cũ).

  const [ txHash, setTxHash ] = useState(null)

Mã băm của giao dịch cuối cùng, được sử dụng để hiển thị một liên kết đến trình khám phá để người dùng có thể kiểm tra giao dịch đó.

  const [ transferToken, setTransferToken ] = useState("")
  const [ transferAmount, setTransferAmount ] = useState("")
  const [ transferTo, setTransferTo ] = useState("")

Tất cả các trường này được sử dụng để gửi các lệnh chuyển token đến một hợp đồng ERC-20. Đây có thể là FaucetToken, nhưng không bắt buộc. Hàm transfer là một phần của tiêu chuẩn ERC-20.

  const balance = useReadContract({
    ...
  })


  const proxyBalance = useReadContract({
    ...
  })

Đọc hai số dư token mà chúng ta quan tâm, người dùng sở hữu bao nhiêu và proxy sở hữu bao nhiêu.

  const nonce = useReadContract({
      address: proxyAddr,
      abi: UserProxy.abi,
      functionName: 'nonce',
      args: [],
  })

Để ngăn chặn các cuộc tấn công phát lại (ví dụ: một người bán phát lại một giao dịch mang lại tiền cho họ), chúng ta sử dụng một nonce (opens in a new tab). Chúng ta cần biết giá trị hiện tại để thêm nó vào dữ liệu mà chúng ta ký.

Sử dụng useEffect (opens in a new tab) để cập nhật số dư được hiển thị cho người dùng khi thông tin đọc từ Chuỗi khối thay đổi.

  useEffect(() => {
    setTransferToken(faucetAddr)
  }, [faucetAddr])

  useEffect(() => {
    setTransferTo(account.address)
  }, [account.address])

Mặc định là chuyển token FaucetToken đến Tài khoản của chính người dùng. Ở đây chúng ta thiết lập các giá trị này khi chúng ta nhận được chúng từ Viem.

  const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
  const transferTokenChange = (evt) => setTransferToken(evt.target.value)
  const transferToChange = (evt) => setTransferTo(evt.target.value)
  const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

Các trình xử lý sự kiện cho khi các trường văn bản thay đổi.

Yêu cầu máy chủ triển khai một proxy cho người dùng này.

  const signMessage = async(proxyAddr, target, calldata) => {

Ký một thông điệp trước khi gửi nó đến máy chủ để gửi đến UserProxy trên chuỗi. Điều này được giải thích tại đây. Chúng ta cần ký một thông điệp với cả Địa chỉ đích (Địa chỉ của token mà chúng ta đang gọi) và dữ liệu lệnh gọi để gửi.

    const domain = {
      .
      .
      .
    return {v, r, s}
  }

  const messageUserProxy = async (proxy, target, data, v, r, s) => {

Gửi một thông điệp đã ký đến UserProxy, nó sẽ xác minh chữ ký và sau đó gửi nó đến target.

Gửi một yêu cầu đến máy chủ và khi bạn nhận được phản hồi, hãy lấy mã băm giao dịch.

  const faucetSimulation = useSimulateContract({
    address: faucetAddr,
    abi: Erc20.abi,
    functionName: 'faucet',
    account: account.address
  })

Mô phỏng việc gọi hàm faucet. Chúng ta chỉ bật nút vòi nếu điều này thành công.

Để gọi một hàm thông qua máy chủ và UserProxy, chúng ta làm theo ba bước:

  1. Tạo dữ liệu lệnh gọi để ký và gửi bằng cách sử dụng encodeFunctionData (opens in a new tab).

  2. Ký thông điệp (Địa chỉ đích, dữ liệu lệnh gọi và nonce).

  3. Gửi thông điệp đến máy chủ.

Phần này của thành phần cho phép bạn sử dụng FaucetToken trực tiếp từ trình duyệt. Mục đích chính của nó là tạo điều kiện thuận lợi cho việc gỡ lỗi.

         <h4>UserProxy access <Address address={proxyAddr} /></h4>
         <button onClick={deployUserProxy}>
         Deploy UserProxy (slow process)
         </button>

Cho phép người dùng triển khai một UserProxy mới.

Chỉ cho phép người dùng nhấp vào Set proxy address khi họ nhập một Địa chỉ hợp pháp. Lưu ý rằng điều này không đảm bảo rằng Địa chỉ được đề cập thực sự là một hợp đồng UserProxy. Có thể thêm một kiểm tra như vậy, nhưng nó sẽ chậm hơn nhiều (trải nghiệm người dùng kém hơn) và không cải thiện bảo mật (những kẻ tấn công luôn có thể sử dụng mã của riêng họ cho giao diện người dùng).

         <br /><br />
         { proxyAddr && (

Hiển thị phần còn lại chỉ khi có một Địa chỉ proxy hợp pháp.

            <>
               Proxy balance: {proxyBalanceAmount}
               <br />
               Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

Người dùng không cần biết nonce; điều này chỉ dành cho mục đích gỡ lỗi.

               <br />
               <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
                  onClick={proxyFaucet}
               >
                  Request more tokens for proxy
               </button>

Chúng ta không thể mô phỏng một lệnh gọi đến faucet() thông qua proxy. Tuy nhiên, ít nhất chúng ta có thể đảm bảo rằng chúng ta có một proxy và proxy đó đã báo cáo một nonce cho chúng ta.

Cho phép người dùng phát hành các giao dịch chuyển ERC-20.

Nếu có mã băm giao dịch cuối cùng, hãy hiển thị một liên kết để người dùng có thể xem nó trong một trình khám phá khối.

 
</div>
    </>
  )
}

export {Token}

Đây chỉ là mã soạn sẵn (boilerplate) của React.

Các lỗ hổng

Máy chủ của chúng ta dễ bị tấn công từ chối dịch vụ. Cuộc tấn công này được giải thích trong bài viết trước của loạt bài.

Ngoài ra, chúng ta đang khuyến khích hành vi xấu của người dùng. Đây là những gì chúng ta yêu cầu người dùng ký:

Screen capture with opaque calldata

Chúng ta biết đây là một giao dịch chuyển ERC-20 hợp pháp cho token, số lượng và Địa chỉ đích mà người dùng muốn chuyển. Nhưng hầu hết người dùng không biết cách diễn giải dữ liệu lệnh gọi và không biết họ đang ký cái gì. Đó là một thiết kế tồi, vì hai lý do:

  • Một số người dùng sẽ không sử dụng chúng ta vì họ không tin tưởng vào dữ liệu mà chúng ta yêu cầu họ ký.
  • Những người dùng khác sẽ tin tưởng chúng ta và học được rằng họ chỉ nên ký dữ liệu lệnh gọi mà không cần hiểu nó là gì. Điều này có nghĩa là nếu Kẻ tấn công Adam (Adam Attacker) tìm cách chuyển hướng họ đến trang web của hắn, hắn có thể yêu cầu họ ký một giao dịch cấp cho hắn tất cả USDC (hoặc DAI, hoặc bất kỳ ERC-20 nào khác) mà người dùng sở hữu.

Giải pháp là có các hàm riêng biệt trong UserProxy cho các hàm thường được sử dụng, chẳng hạn như chuyển. Sau đó, người dùng có thể ký một cái gì đó mà họ hiểu.

Screen capture with transfer details

Lưu ý: Mặc dù người dùng có thể sử dụng bất kỳ Ví nào họ muốn, nhưng chúng tôi đặc biệt khuyến nghị các ứng dụng sử dụng EIP-712 khuyến khích họ sử dụng một Ví hiển thị toàn bộ dữ liệu chữ ký (opens in a new tab). Một số Ví cắt bớt Địa chỉ, điều này không an toàn. Kẻ tấn công có thể tạo một Địa chỉ có các ký tự bắt đầu và kết thúc giống nhau, nhưng khác nhau ở giữa.

Screen capture with truncated addresses

Kết luận

Ngoài các lỗ hổng ở trên, giải pháp trong hướng dẫn này có một số nhược điểm mà Ethereum có thể giúp chúng ta giải quyết.

  • Khả năng chống kiểm duyệt. Hiện tại, người dùng có thể sử dụng máy chủ của bạn, một máy chủ cạnh tranh do người khác thiết lập hoặc kết nối trực tiếp với Ethereum, điều này phát sinh chi phí Gas. Sử dụng ERC-4337 (opens in a new tab) cho phép người dùng cung cấp giao dịch của họ cho một nhóm lớn các máy chủ, giảm khả năng các giao dịch của họ bị kiểm duyệt.
  • Tài sản do EOA sở hữu. Như đã lưu ý ở trên, EIP-7702 (opens in a new tab) có thể được sử dụng để quản lý các tài sản đã thuộc sở hữu của một Địa chỉ EOA. Điều này có những khó khăn riêng, nhưng đôi khi nó là cần thiết.

Tôi hy vọng sẽ xuất bản các hướng dẫn về việc thêm các tính năng này trong tương lai gần.

Xem thêm các tác phẩm của tôi tại đây (opens in a new tab).