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

Tài trợ phí Gas: Cách chi trả chi phí giao dịch cho người dùng của bạn

không cần gas
Solidity
eip-712
giao dịch meta
Trung gian
Ori Pomerantz
27 tháng 2, 2026
14 số phút đọc

Giới thiệu

Nếu chúng ta muốn Ethereum phục vụ thêm một tỷ người nữa (opens in a new tab), chúng ta cần loại bỏ các rào cản và làm cho nó dễ sử dụng nhất có thể. Một trong những rào cản này là việc cần có ETH để trả phí Gas.

Nếu bạn có một ứng dụng phi tập trung (dapp) kiếm tiền từ người dùng, có thể hợp lý khi cho phép người dùng gửi các giao dịch thông qua máy chủ của bạn và tự bạn trả phí giao dịch. Bởi vì người dùng vẫn ký một thông điệp ủy quyền EIP-712 (opens in a new tab) trong Ví của họ, họ vẫn giữ được các đảm bảo về tính toàn vẹn của Ethereum. Tính khả dụng phụ thuộc vào máy chủ chuyển tiếp các giao dịch, do đó nó bị giới hạn hơn. Tuy nhiên, bạn có thể thiết lập để người dùng cũng có thể truy cập trực tiếp vào hợp đồng thông minh (nếu họ có ETH), và cho phép những người khác thiết lập máy chủ của riêng họ nếu họ muốn tài trợ cho các giao dịch.

Kỹ thuật trong hướng dẫn này chỉ hoạt động khi bạn kiểm soát hợp đồng thông minh. Có những kỹ thuật khác, bao gồm trừu tượng hóa tài khoản (opens in a new tab) cho phép bạn tài trợ các giao dịch cho các hợp đồng thông minh khác, mà tôi hy vọng sẽ đề cập trong một bài hướng dẫn tương lai.

Lưu ý: Đây không phải là mã cấp độ sản xuất (production-level). Nó dễ bị tấn công nghiêm trọng và thiếu các tính năng chính. Tìm hiểu thêm trong phần lỗ hổng bảo mật của hướng dẫn này.

Điều kiện tiên quyết

Để hiểu hướng dẫn này, bạn cần phải làm quen với:

Ứng dụng mẫu

Ứng dụng mẫu ở đây là một biến thể của hợp đồng Greeter của Hardhat. Bạn có thể xem nó trên GitHub (opens in a new tab). Hợp đồng thông minh đã được triển khai trên Sepolia (opens in a new tab), tại địa chỉ 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).

Để xem nó hoạt động như thế nào, hãy làm theo các bước sau.

  1. Sao chép (clone) kho lưu trữ và cài đặt phần mềm cần thiết.

    1git clone https://github.com/qbzzt/260301-gasless.git
    2cd 260301-gasless/server
    3npm install
  2. Chỉnh sửa .env để thiết lập 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ý 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.

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

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

  5. 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.

  6. Viết một lời chào mới và nhấp vào Update greeting via sponsor.

  7. Ký thông điệp.

  8. Đợi khoảng 12 giây (thời gian tạo khối trên Sepolia). Trong khi chờ đợi, bạn có thể nhìn vào URL trong bảng điều khiển của máy chủ để xem giao dịch.

  9. Thấy rằng lời chào đã thay đổi và giá trị địa chỉ cập nhật lần cuối hiện là địa chỉ Ví trình duyệt của bạn.

Để hiểu cách thức hoạt động của nó, chúng ta cần xem xét cách thông điệp được tạo ra trong giao diện người dùng, cách nó được chuyển tiếp bởi máy chủ và cách hợp đồng thông minh xử lý nó.

Giao diện người dùng

Giao diện người dùng dựa trên WAGMI (opens in a new tab); bạn có thể đọc về nó trong hướng dẫn này.

Đây là cách chúng ta ký thông điệp:

1const signGreeting = useCallback(

React hook useCallback (opens in a new tab) cho phép chúng ta cải thiện hiệu suất bằng cách tái sử dụng cùng một hàm khi thành phần (component) được vẽ lại.

1 async (greeting) => {
2 if (!account) throw new Error("Wallet not connected")

Nếu không có Tài khoản, hãy đưa ra lỗi. Điều này không bao giờ nên xảy ra vì nút giao diện người dùng bắt đầu quá trình gọi signGreeting đã bị vô hiệu hóa trong trường hợp đó. Tuy nhiên, các lập trình viên trong tương lai có thể loại bỏ biện pháp bảo vệ đó, vì vậy việc kiểm tra điều kiện này ở đây cũng là một ý tưởng tốt.

1 const domain = {
2 name: "Greeter",
3 version: "1",
4 chainId,
5 verifyingContract: contractAddr,
6 }

Các tham số cho bộ phân tách miền (domain separator) (opens in a new tab). Giá trị này là hằng số, vì vậy trong một triển khai được tối ưu hóa tốt hơn, chúng ta có thể tính toán nó một lần thay vì tính toán lại mỗi khi hàm được gọi.

  • name là một tên mà người dùng có thể đọc được, chẳng hạn như tên của dapp mà chúng ta đang tạo chữ ký cho nó.
  • version là phiên bản. Các phiên bản khác nhau không tương thích với nhau.
  • chainId là Chuỗi mà chúng ta đang sử dụng, được cung cấp bởi WAGMI (opens in a new tab).
  • verifyingContract là địa chỉ hợp đồng sẽ xác minh chữ ký này. Chúng ta không muốn cùng một chữ ký áp dụng cho nhiều hợp đồng, trong trường hợp có một vài hợp đồng Greeter và chúng ta muốn chúng có những lời chào khác nhau.
1
2 const types = {
3 GreetingRequest: [
4 { name: "greeting", type: "string" },
5 ],
6 }

Kiểu dữ liệu mà chúng ta ký. Ở đây, chúng ta có một tham số duy nhất, greeting, nhưng các hệ thống thực tế thường có nhiều hơn.

1 const message = { greeting }

Thông điệp thực tế mà chúng ta muốn ký và gửi. greeting vừa là tên trường vừa là tên của biến điền vào nó.

1 const signature = await signTypedDataAsync({
2 domain,
3 types,
4 primaryType: "GreetingRequest",
5 message,
6 })

Thực sự lấy chữ ký. Hàm này là bất đồng bộ vì người dùng mất nhiều thời gian (từ góc độ của máy tính) để ký dữ liệu.

1 const r = `0x${signature.slice(2, 66)}`
2 const s = `0x${signature.slice(66, 130)}`
3 const v = parseInt(signature.slice(130, 132), 16)
4
5 return {
6 req: { greeting },
7 v,
8 r,
9 s,
10 }
11 },

Hàm trả về một giá trị thập lục phân duy nhất. Ở đây chúng ta chia nó thành các trường.

1 [account, chainId, contractAddr, signTypedDataAsync],
2)

Nếu bất kỳ biến nào trong số này thay đổi, hãy tạo một phiên bản mới của hàm. Các tham số accountchainId có thể được thay đổi bởi người dùng trong Ví. contractAddr là một hàm của ID Chuỗi. signTypedDataAsync không nên thay đổi, nhưng chúng ta nhập nó từ một hook (opens in a new tab), vì vậy chúng ta không thể chắc chắn, và tốt nhất là thêm nó vào đây.

Bây giờ lời chào mới đã được ký, chúng ta cần gửi nó đến máy chủ.

1 const sponsoredGreeting = async () => {
2 try {

Hàm này nhận một chữ ký và gửi nó đến máy chủ.

1 const signedMessage = await signGreeting(newGreeting)
2 const response = await fetch("/server/sponsor", {

Gửi đến đường dẫn /server/sponsor trong máy chủ mà chúng ta xuất phát.

1 method: "POST",
2 headers: { "Content-Type": "application/json" },
3 body: JSON.stringify(signedMessage),
4 })

Sử dụng POST để gửi thông tin được mã hóa JSON.

1 const data = await response.json()
2 console.log("Server response:", data)
3 } catch (err) {
4 console.error("Error:", err)
5 }
6 }

Xuất phản hồi. Trên một hệ thống sản xuất, chúng ta cũng sẽ hiển thị phản hồi cho người dùng.

Máy chủ

Tôi thích sử dụng Vite (opens in a new tab) làm front-end của mình. Nó tự động phục vụ các thư viện React và cập nhật trình duyệt khi mã front-end thay đổi. Tuy nhiên, Vite không bao gồm các công cụ backend.

Giải pháp nằm trong index.js (opens in a new tab).

1 app.post("/server/sponsor", async (req, res) => {
2 ...
3 })
4
5 // Hãy để Vite xử lý mọi thứ khác
6 const vite = await createViteServer({
7 server: { middlewareMode: true }
8 })
9
10 app.use(vite.middlewares)

Đầu tiên, chúng ta đăng ký một trình xử lý cho các yêu cầu mà chúng ta tự xử lý (POST đến /server/sponsor). Sau đó, chúng ta tạo và sử dụng một máy chủ Vite để xử lý tất cả các URL khác.

1 app.post("/server/sponsor", async (req, res) => {
2 try {
3 const signed = req.body
4
5 const txHash = await sepoliaClient.writeContract({
6 address: greeterAddr,
7 abi: greeterABI,
8 functionName: 'sponsoredSetGreeting',
9 args: [signed.req, signed.v, signed.r, signed.s],
10 })
11 } ...
12 })

Đây chỉ là một lệnh gọi Chuỗi khối viem (opens in a new tab) tiêu chuẩn.

Hợp đồng thông minh

Cuối cùng, Greeter.sol (opens in a new tab) cần xác minh chữ ký.

1 constructor(string memory _greeting) {
2 greeting = _greeting;
3
4 DOMAIN_SEPARATOR = keccak256(
5 abi.encode(
6 keccak256(
7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
8 ),
9 keccak256(bytes("Greeter")),
10 keccak256(bytes("1")),
11 block.chainid,
12 address(this)
13 )
14 );
15 }

Hàm khởi tạo tạo ra bộ phân tách miền (opens in a new tab), tương tự như mã giao diện người dùng ở trên. Việc thực thi trên Chuỗi khối tốn kém hơn nhiều, vì vậy chúng ta chỉ tính toán nó một lần.

1 struct GreetingRequest {
2 string greeting;
3 }

Đây là cấu trúc được ký. Ở đây chúng ta chỉ có một trường.

1 bytes32 private constant GREETING_TYPEHASH =
2 keccak256("GreetingRequest(string greeting)");

Đây là định danh cấu trúc (opens in a new tab). Nó được tính toán mỗi lần trong giao diện người dùng.

1 function sponsoredSetGreeting(
2 GreetingRequest calldata req,
3 uint8 v,
4 bytes32 r,
5 bytes32 s
6 ) external {

Hàm này nhận một yêu cầu đã ký và cập nhật lời chào.

1 // Tính toán bản tóm tắt EIP-712
2 bytes32 digest = keccak256(
3 abi.encodePacked(
4 "\x19\x01",
5 DOMAIN_SEPARATOR,
6 keccak256(
7 abi.encode(
8 GREETING_TYPEHASH,
9 keccak256(bytes(req.greeting))
10 )
11 )
12 )
13 );

Tạo bản tóm tắt (digest) theo EIP 712 (opens in a new tab).

1 // Khôi phục người ký
2 address signer = ecrecover(digest, v, r, s);
3 require(signer != address(0), "Invalid signature");

Sử dụng ecrecover (opens in a new tab) để lấy Địa chỉ người ký. Lưu ý rằng một chữ ký sai vẫn có thể tạo ra một Địa chỉ hợp lệ, chỉ là một địa chỉ ngẫu nhiên.

1 // Áp dụng lời chào như thể người ký đã gọi nó
2 greeting = req.greeting;
3 emit SetGreeting(signer, req.greeting);
4 }

Cập nhật lời chào.

Lỗ hổng bảo mật

Đây không phải là mã cấp độ sản xuất. Nó dễ bị tấn công nghiêm trọng và thiếu các tính năng chính. Dưới đây là một số lỗ hổng, cùng với cách giải quyết chúng.

Để xem một số cuộc tấn công này, hãy nhấp vào các nút dưới tiêu đề Attacks và xem điều gì xảy ra. Đối với nút Invalid signature, hãy kiểm tra bảng điều khiển máy chủ để xem phản hồi giao dịch.

Từ chối dịch vụ trên máy chủ

Cuộc tấn công dễ nhất là tấn công từ chối dịch vụ (denial-of-service) (opens in a new tab) trên máy chủ. Máy chủ nhận các yêu cầu từ bất kỳ đâu trên Internet và dựa trên các yêu cầu đó để gửi các giao dịch. Hoàn toàn không có gì ngăn cản kẻ tấn công phát hành một loạt các chữ ký, hợp lệ hoặc không hợp lệ. Mỗi chữ ký sẽ gây ra một giao dịch. Cuối cùng, máy chủ sẽ hết ETH để trả cho Gas.

Một giải pháp cho vấn đề này là giới hạn tỷ lệ ở mức một giao dịch mỗi khối. Nếu mục đích là hiển thị lời chào cho các tài khoản thuộc sở hữu bên ngoài (externally owned accounts), thì dù sao lời chào ở giữa khối là gì cũng không quan trọng.

Một giải pháp khác là theo dõi các Địa chỉ và chỉ cho phép chữ ký từ các khách hàng hợp lệ.

Chữ ký lời chào sai

Khi bạn nhấp vào Signature for wrong greeting, bạn gửi một chữ ký hợp lệ cho một Địa chỉ cụ thể (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) và lời chào (Hello). Nhưng nó lại gửi với một lời chào khác. Điều này làm rối ecrecover, khiến nó thay đổi lời chào nhưng lại có Địa chỉ sai.

Để giải quyết vấn đề này, hãy thêm Địa chỉ vào cấu trúc được ký (opens in a new tab). Bằng cách này, Địa chỉ ngẫu nhiên của ecrecover sẽ không khớp với Địa chỉ trong chữ ký và hợp đồng thông minh sẽ từ chối thông điệp.

Tấn công phát lại (Replay attacks)

Khi bạn nhấp vào Replay attack, bạn gửi cùng một chữ ký "Tôi là 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, và tôi muốn lời chào là Hello", nhưng với lời chào chính xác. Kết quả là, hợp đồng thông minh tin rằng Địa chỉ (không phải của bạn) đã thay đổi lời chào trở lại thành Hello. Thông tin để thực hiện việc này có sẵn công khai trong thông tin giao dịch (opens in a new tab).

Nếu đây là một vấn đề, một giải pháp là thêm một nonce (opens in a new tab). Có một ánh xạ (mapping) (opens in a new tab) giữa các Địa chỉ và các con số, và thêm một trường nonce vào chữ ký. Nếu trường nonce khớp với ánh xạ cho Địa chỉ, hãy chấp nhận chữ ký và tăng ánh xạ cho lần tiếp theo. Nếu không, hãy từ chối giao dịch.

Một giải pháp khác là thêm dấu thời gian (timestamp) vào dữ liệu được ký và chỉ chấp nhận chữ ký là hợp lệ trong vài giây sau dấu thời gian đó. Cách này đơn giản và rẻ hơn, nhưng chúng ta có nguy cơ bị tấn công phát lại trong khoảng thời gian đó, và sự thất bại của các giao dịch hợp pháp nếu vượt quá khoảng thời gian.

Các tính năng còn thiếu khác

Có những tính năng bổ sung mà chúng ta sẽ thêm vào trong môi trường sản xuất.

Truy cập từ các máy chủ khác

Hiện tại, chúng ta cho phép bất kỳ Địa chỉ nào gửi một sponsorSetGreeting. Đây có thể chính xác là những gì chúng ta muốn, vì lợi ích của sự phi tập trung. Hoặc có thể chúng ta muốn đảm bảo rằng các giao dịch được tài trợ đi qua máy chủ của chúng ta, trong trường hợp đó chúng ta sẽ kiểm tra msg.sender trong hợp đồng thông minh.

Dù bằng cách nào, đây nên là một quyết định thiết kế có ý thức, chứ không chỉ là kết quả của việc không suy nghĩ về vấn đề này.

Xử lý lỗi

Một người dùng gửi một lời chào. Có thể nó được cập nhật ở khối tiếp theo. Có thể không. Các lỗi là vô hình. Trên một hệ thống sản xuất, người dùng sẽ có thể phân biệt giữa các trường hợp này:

  • Lời chào mới chưa được gửi
  • Lời chào mới đã được gửi và đang trong quá trình xử lý
  • Lời chào mới đã bị từ chối

Kết luận

Tại thời điểm này, bạn đã có thể tạo ra trải nghiệm không cần Gas cho người dùng dapp của mình, với cái giá là một chút tập trung hóa.

Tuy nhiên, điều này chỉ hoạt động với các hợp đồng thông minh hỗ trợ ERC-712. Ví dụ, để chuyển một token ERC-20, cần phải có giao dịch được ký bởi chủ sở hữu thay vì chỉ là một thông điệp. Giải pháp là trừu tượng hóa tài khoản (ERC-4337) (opens in a new tab). Tôi hy vọng sẽ viết một bài hướng dẫn trong tương lai về nó.

Xem thêm các bài viết của tôi tại đây (opens in a new tab).

Cập nhật trang lần cuối: 3 tháng 3, 2026

Hướng dẫn này có hữu ích không?