Skip to content

Account Abstraction với ERC-4337 (Part 2): Sponsoring transactions using paymasters

Posted on:April 5, 2023

Intro

Trong phần 1 ta đã mô phỏng lại cách mà một smart contract wallet được tạo ra theo ERC-4337. Tuy nhiên hiện tại thì wallet vẫn phải tự mình trả phí gas, điều đó có nghĩa là trước khi nó có thể thực hiện bất cứ hoạt động on-chain nào, nó phải được fund sẵn một lượng ETH nào đó.

Sẽ ra sao nếu một ai đó khác có thể thay wallet owner để trả tiền gas?

Có vài lý do rất hợp lý để làm điều này:

Giới thiệu paymaster

Ví dụ ta đang là một dapp mà muốn trả phí gas giúp user. Tất nhiên ta sẽ không muốn trả hộ cho tất cả mọi người tất cả các operation, nên ta sẽ đưa vào những custom logic mà ta có thể dựa vào đó để đánh giá xem liệu op đó có được ta trả phí hộ hay không?

Ta sẽ đưa vào một contract mới, gọi là paymaster. Nó sẽ có một method nhận đầu vào là user operation để đánh giá xem nó có sẵn sàng trả phí gas cho op đó hay không:

contract Paymaster {
  function validatePaymasterOp(UserOperation op);
}

Sau đó, mỗi khi wallet submit một op, op này cần địa chỉ của paymaster (nếu có) sẽ trả phí gas cho nó.

Ta sẽ add thêm trường này vào trong struct UserOperation. Ta cũng sẽ add thêm một trường paymasterData nữa vào trong user op để wallet có thể pass dữ liệu bất kì cho paymaster để paymaster có thêm thông tin đánh giá. Ví dụ nó có thể là một message được kí off-chain bởi paymaster owner chẳng hạn.

struct UserOperation {
  // ...
  address paymaster;
  bytes paymasterData;
}

Tiếp theo chúng ta sẽ thay đổi handleOps trong Entry Point để sử dụng paymaster. Lúc này handleOps sẽ thực hiện với mỗi op:

Giống như các wallet, các paymaster sẽ deposit ETH vào Entry Point thông qua deposit trước khi nó có thể được dùng để trả phí cho các op.

aa-06

Trông có vẻ đơn giản đúng không?

Paymaster staking

Ở trong bài trước ta đã nói về việc bundler sẽ phải simulate validation để tránh việc thực thi một transaction mà bị fail validation; vì khi này wallet sẽ không trả tiền phí giao dịch cho bundler.

Ở đây ta cũng gặp một vấn đề tương tự: bundler muốn tránh việc submit những op mà fail paymaster validation bởi khi này paymaster cũng sẽ không trả phí giao dịch lại cho bundler.

Đầu tiên ta có thể nghĩ ngay đến việc vấn đề tương tự thì ta có thể có solution tương tự, là đưa vào các giới hạn trong validatePaymasterOp giống như ta đã làm với validateOp, và sau đó thì bundler sẽ chỉ việc simulate validatePaymasterOp cho user op cùng lúc với việc simulate validateOp tại wallet.

Nhưng có một điểm khác biệt ở đây.

Bởi vì các giới hạn về việc truy cập storage, validateOp sẽ chỉ truy cập được các associated storage của wallet mà thôi, do đó việc validation của nhiều op trong bundle sẽ không bị xung đột với nhau vì chúng đến từ những wallet khác nhau và rất hiếm khi truy cập những storage chung.

Nhưng storage của paymaster được share giữa toàn bộ những operation trong bundle mà sử dụng paymaster đó. Điều đó cũng có nghĩa là rất nhiều validatePaymasterOp có khả năng sẽ bị fail vì những op đó trong bundle đang sử dụng chung một paymaster.

Một paymaster độc hại có thể sử dụng điều này để tiến hành DoS hệ thống.

Để tránh điều này, ta giới thiệu hệ thống reputation system.

Hệ thống này sẽ theo dõi mức độ thường xuyên mà paymaster có failed validation, và có thể sẽ tiến hành phạt hoặc ban nếu mức độ vượt qua một ngưỡng nào đó.

Tuy nhiên giải pháp này cũng có thể bị bypass nếu nếu kẻ tấn công tạo ra rất nhiều những paymaster độc hại khác nhau để giữ cho mức độ thường xuyên của failed validation luôn ở dưới ngưỡng (gọi là Sibyl Attack). Do đó ta cần thêm một lớp giải pháp nữa, là yêu cầu các paymaster phải stake một lượng ETH đủ lớn. Khi này cái giá để thực hiện Sibyl Attack sẽ rất cao đến mức không đáng để thực hiện.

Ta sẽ thêm phương thức stake vào trong Entry Point:

contract EntryPoint {
  // ...

  function addStake() payable;
  function unlockStake();
  function withdrawStake(address payable destination);
}

Một khi đã stake, nếu muốn thực hiện rút tiền bằng unlockStake(), nó sẽ mất một khoảng thời gian delay nhất định nào đó.

Các hàm này hoàn toàn khác với các hàm nạp rút ta đã đề cập ở bài trước là depostwithdrawTo, vốn được dùng bởi walletpaymaster để nạp ETH sử dụng cho việc trả phí, và có thể rút bất cứ lúc nào.

Ở đây có một ngoại lệ cho việc staking:

Nếu paymaster chi truy cập vào associated storage của wallet mà không hề truy cập vào storage của chính paymaster, thì ta không cần thiết phải yêu cầu stake. Bởi trong trường hợp này thì việc truy cập storage của validatePaymasterOp trong các op sẽ không bị xung đột với nhau, giống như với validateOp.

Cải tiến: Paymaster postOp

Ta có thể tại một cải tiến nhỏ để cho phép paymaster có thể làm được nhiều thứ hơn. Hiện tại, paymaster mới chỉ có thể được gọi tại bước validation, ngay trước khi operation được thực thi.

Nhưng paymaster có thể sẽ vẫn cần khả năng take action dựa trên kết quả trả về của operation. Ví dụ, một paymaster cho phép user có thể trả phí gas bằng USDC sẽ cần biết chính xác bao nhiêu gas đã được sử dụng để convert nó qua USDC mà người dùng sẽ phải trả.

Theo đó, ta sẽ thêm một phương thức mới và trong paymaster là postOp để Entry Point sẽ gọi sau khi operation done và đưa vào số lượng gas đã được sử dụng.

Ta cũng muốn paymaster có khả năng pass thông tin cho chính nó để sử dụng data mà nó đã tính toán ở bước validation trong bước postOp, do đó ở bước validation ta sẽ trả về một context data, data này sẽ được pass vào postOp.

contract Paymaster {
  function validatePaymasterOp(UserOperation op) returns (bytes context);
  function postOp(bytes context, uint256 actualGasCost);
}

Ở đây ta có một trick xử lý nhỏ trong paymaster khi ta muốn user trả phí bằng USDC.

Giả thiết rằng paymaster đã check rằng user có đủ lượng USDC tại thời điểm trước khi op được thực hiện (tức trong validatePaymasterOp). Nhưng khi thực hiện op thì hoàn toàn có thể op đó gửi toàn bộ USDC của user cho người khác chẳng hạn, thì rõ ràng lúc sau paymaster không thể lấy USDC từ user để trả phí nữa rồi!

paymaster có thể tránh được việc này bằng cách charge lượng USDC max ngay tại thời điểm start, sau đó refund lại sau cho user được không? giống như khi trả phí bằng ETH vậy. Tất nhiên cách đó cũng ok, nhưng nó rất cồng kềnh: sẽ mất 2 lần gọi transfer thay vì 1 lần, sẽ tăng gas cost, đồng thời bắn ra 2 event Transfer. Ta có thể làm tốt hơn vậy.

Ta cần một giải pháp để paymaster có thể đánh fail operation kể cả khi nó đã thực thi xong, và ngay cả trong trường hợp đó, thì ta vẫn phải có khả năng trích xuất phí giao dịch, vì rõ ràng bằng việc validatePaymasterOp success, user đã đồng ý trả phí cho operation bất kể kết quả thực hiện ra sao.

Giải pháp chính là cung cấp khả năng gọi postOp lần thứ hai cho Entry Point.

Lần đầu Entry Point gọi postOp như là một phần của execution khi thực hiện chung với executeOp, và do đó nếu postOp revert, nó sẽ làm cho cả executeOp cũng revert theo.

Nếu điều này xảy ra, thì Entry Point sẽ gọi tiếp postOp một lần nữa, nhưng lần này ta đang ở trong trạng thái trước khi gọi executeOp, tức là khi ta vừa validatePaymasterOp xong, khi này paymaster sẽ có thể trích xuất được phí giao dịch ra.

Để cung cấp thêm thông tin cho postOp, ta sẽ đưa cho nó thêm một tham số nữa: một flag để chỉ ra chúng ta có đang ở lần chạy thứ hai sau khi lần đầu tiên đã reverted hay không:

contract Paymaster {
  function validatePaymasterOp(UserOperation op) returns (bytes context);
  function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}

Recap: How paymaster enable sponsored transactions

Để có thể cho phép ai đó khác wallet owner trả phí gas, ta giới thiệu contract paymaster có interface như sau:

contract Paymaster {
  function validatePaymasterOp(UserOperation op) returns (bytes context);
  function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}

đồng thời add thêm các trường dữ liệu mới vào bên trong User Operation:

struct UserOperation {
  // ...
  address paymaster;
  bytes paymasterData;
}

paymaster sẽ deposit ETH vào trong Entry Point để trả phí giao dịch giống như cách mà wallet đã làm.

Entry Point lúc này update handleOps, với mỗi op trước đây chỉ thực hiện validateOp, thì giờ đây nếu op có paymaster thì thực hiện thêm validatePaymasterOp, sau đó thực hiện operation, cuối cùng thì gọi postOp trong paymaster.

Để tránh những vấn đề về fail simulate validation của paymaster và Sibyl Attack, ta giới thiệu một hệ thống reputation bằng cách yêu cầu paymaster stake ETH vào Entry Point:

contract EntryPoint {
  // ...

  function addStake() payable;
  function unlockStake();
  function withdrawStake(address payable destination);
}

Với sự góp mặt của paymaster, ta đã đạt được hầu như tất cả các feature lớn khi nói đến Account Abstraction.

Ta đã tiến rất gần đến ERC-4337 rồi, chỉ còn vài features nữa thôi là sẽ thành một phiên bản hoàn thiện.

Tham khảo