Skip to content

Account Abstraction với ERC-4337 (Part 1): UserOperation, Wallet, EntryPoint, Bundler

Posted on:April 4, 2023

ERC-4337 sử dụng khái niệm account, về bản chất nó là một smart contract, nên trong bài này đôi khi ta sử dụng các từ khóa smart contract account, smart contract wallet, wallet ta hiểu ý nghĩa của chúng là tương đương.

ERC-4437 là gì?

ERC-4337 được đề xuất bởi EIP-4337 (Account Abstraction via Entry Point Contract specification), là một đề xuất sử dụng EntryPoint contract để đạt được account abstraction mà không làm thay đổi consensus layer protocol của Ethereum.

Thay vì phải thay đổi logic của consensus layer, ERC-4337 mô phỏng lại cách hoạt động của hệ thống transaction mempool nhưng ở một higher-level. User sẽ gửi các UserOperation đóng gói transaction data kèm chữ kí của user. Miner hoặc bundler sử dụng các dịch vụ như Flashbot có thể đóng gói nhiều UserOperation thành một bundle transaction và đưa vào block như một transaction thông thường.

erc-4337

ERC-4337 cũng giới thiệu một cơ chế paymaster cho phép đa dạng hóa việc trả phí:

Hiện nay ERC-4337 vẫn đang trong giai đoạn draft và chưa hoàn thiện hoàn toàn. Tuy nhiên vì ERC-4337 không làm thay đổi Ethereum protocol, nên rất nhiều implement đã được thực hiện và đưa ra thị trường, ví dụ:

Trong bài này ta sẽ đi vào tìm hiểu cách mà ERC-4337 implement account abstraction, nhưng thay vì trực tiếp nhảy vào đề xuất gốc của ERC-4337 sẽ rất khó hiểu, ta sẽ đi theo hướng bottom-up từ bài toán nhỏ nhất, và phát triển dần cho tới khi đạt được kiến trúc hoàn thiện của ERC-4337.

Ok, let’s get started!

User operations

Ta biết rằng với Account Abstraction, account của chúng ta là một smart contract wallet, có khả năng lưu trữthao tác với asset trong đó. Ta sẽ viết một contract có một hàm executeOp nhận đầu vào là một UserOperation như sau:

contract Wallet {
  function executeOp(UserOperation op);
}

trong đó UserOperation sẽ bao gồm các trường giống như khi ta gửi transaction thông thường bằng eth_sendTransaction:

struct UserOperation {
  address to;
  bytes data;
  uint256 value; // Amount of wei sent
  uint256 gas;
  // ...
}

ta sẽ cần thêm thông tin để xác thực xem transaction này có được gửi từ đúng người hay không? nếu đúng thì ta mới thực hiện nó và revert trong trường hợp ngược lại. Trong hầu hết các trường hợp, ta sẽ thêm vào chữ ký của người gửi và nonce để tránh replaying attack.

struct UserOperation {
  // ...
  bytes signature;
  uint256 nonce;
}

Dù về mặt lý thuyết wallet có thể thực hiện bất cứ verification logic nào mà nó muốn với signaturenonce, tuy nhiên về mặt practice thì wallet nên yêu cầu signature là chữ ký với toàn bộ những trường còn lại trong op, điều này tránh được việc một bên nào đó bắt được gói tin và thay đổi hoặc làm giả các trường dữ liệu trong op. Tương tự vậy, wallet cũng nên reject tất cả những op với số nonce đã được sử dụng.

Ai sẽ là người call smart contract wallet?

Vì op hoàn toàn nằm trong kiểm soát của op owner về dữ liệu và chữ ký, nên hàm executeOp(op) có thể được gọi bởi bất kì ai mà không hề có rủi ro nào về bảo mật.

Trong Ethereum, tất cả các transaction đều phải bắt nguồn từ một EOA, và EOA sẽ trả phí giao dịch bằng ETH, nên lúc này ta có thể thêm vào hệ thống một EOA riêng để gọi hàm của smart contract wallet. EOA này sẽ chỉ cần hold một lượng ETH vừa đủ để trả phí giao dịch mà thôi, còn mọi các tài sản giá trị khác sẽ vẫn nằm trong smart contract wallet.

Bức tranh về account abstraction với smart contract wallet hiện tại đang trông thế này:

aa-01

Goal: no separate EOA

Một điểm yếu của phương án trên là ta sẽ luôn cần phải có một EOA bên ngoài để gọi hàm từ wallet. Sẽ thế nào nếu ta không muốn duy trì một account ngoài như vậy? Hiện tại, ta vẫn muốn trực tiếp trả phí gas bằng ETH. Ta chỉ đơn giản không muốn phải sử dụng thêm một account khác mà thôi.

Như đã nói executeOp(op) có thể được gọi bởi bất kì ai, do đó ta hoàn toàn có thể nhờ ai đó sử dụng EOA của họ để gọi hàm. Người này sẽ được gọi là executor

Tất nhiên không ai muốn làm việc không công cả, vì executor phải trả phí giao dịch, nên phương án ở đây là ta sẽ hold một ít ETH ở smart contract wallet, và tiến hành hoàn trả phí gas cho executor mỗi khi họ gọi hàm executeOp(op) giúp mình.

“Executor” không phải là một khái niệm trong ERC-4337, nhưng ở hiện tại nó mô tả chính xác vai trò của nó trong hệ thống. Về sau, ta sẽ thay thế nó bằng khái niệm thực tế được sử dụng trong ERC-4337 là “bundler”, nó không có ý nghĩa gì lắm ở lúc này vì ta đang không tiến hành đóng gói cái gì cả. Ở một số protocol khác cũng có thể gọi nó là “relayer”.

Thử nghiệm đầu tiên: Wallet sẽ refund cho executor khi xong transaction

Quay trở lại với interface của wallet hiện tại:

contract Wallet {
  function executeOp(UserOperation op);
}

tại cuối của hàm executeOp, ta sẽ tính toán xem nó sử dụng hết bao nhiêu gas để refund lượng ETH phù hợp lại cho executor.

aa-02

Với một trusted wallet, thì thiết kế này hoàn toàn ổn. Nhưng executor cần biết chắc chắn rằng wallet sẽ thực sự refund phí giao dịch họ đã trả. Nếu như executor thực hiện xong executeOp mà wallet không thực sự refund tiền thì rõ ràng là excutor công cốc.

Để tránh kịch bản xấu này, executor có thể tiến hành simulate executeOp tại local, ví dụ sử dụng debug_traceCall và xem nó có thực sự refund phí giao dịch hay không? Nếu có thì mới thực hiện executeOp thực sự.

Nhưng có một vấn đề ở đây là simulation sẽ không hoàn toàn thể hiện được 100% transaction trong thực tế. Cũng có nghĩa là nó có thể success ở simulation, nhưng fail ở execution. Một wallet độc hại có thể làm điều này, để transaction được thực hiện free mà không phải trả một khoản phí nào cả.

Lý do mà transaction có thể khác giữa simulation và execution thực tế có thể kể đến như:

Ta có thể nghĩ đến một giải pháp tình thế, đấy là giới hạn các opcodes được phép sử dụng trong UserOperation, và reject những opcodes như kể trên. Tuy nhiên điều này không tốt lắm, vì việc giới hạn các opcodes có thể dẫn tới việc rất nhiều những transaction hợp lệ cũng bị giới hạn theo, một trong số đó có thể kể đến như việc tương tác với Uniswap sử dụng TIMESTAMP opcode.

executeOp có thể thực hiện bất kì logic code nào, nên thật khó có thể giới hạn để tránh việc nó thực hiện bypass simulation. Vấn đề này dường như bất khả thi với interface wallet hiện tại.

Giải pháp tốt hơn: giới thiệu EntryPoint

Vấn đề ở đây là ta đang đòi hỏi executor thực hiện code từ một untrusted contract, việc này rõ ràng đẩy rủi ro về cho executor. Cái mà executor muốn là chạy code với một môi trường được đảm bảo. Điều này có thể đạt được bằng cách sử dụng smart contract, nên ở đây ta sẽ thêm vào một trusted contract mới gọi là EntryPoint, nó sẽ cung cấp một phương thức cho executor gọi:

contract EntryPoint {
  function handleOp(UserOperation op);

  // ...
}

handleOp sẽ thực hiện các công việc sau:

Với công việc cuối cùng, ta cần Entry Point phải hold một lượng ETH nào đấy để nó có thể gửi lại phí giao dịch cho executor thay vì wallet, vì ta không luôn chắc chắn rằng có thể lấy lại phí giao dịch từ wallet. Cũng theo đó thì Entry Point sẽ cần một phương thức để wallet hoặc một bên thứ 3 nào đó nạp ETH vào để trả phí gas cho nó; đồng thời cần một phương thức để lấy lại ETH khi cần.

contract EntryPoint {
  // ...

  function deposit(address wallet) payable;
  function withdrawTo(address payable destination);
}

Với phương án này, executor sẽ luôn đảm bảo được trả lại phí giao dịch từ trusted contract EntryPoint.

aa-03

Đây là một bước cải tiến lớn cho executor, nhưng lại nảy sinh vấn đề lớn khác cho wallet…

Chẳng phải chúng ta cũng có thể trả phí gas sử dụng ETH từ chính wallet, thay vì phải deposit vào trong Entry Point hay sao? Đúng ta có thể, nhưng ta sẽ cần vài thay đổi để thực hiện nó, ta sẽ giới thiệu ở phần sau đây, và kể cả khi làm như vậy thì ta cũng vẫn cần có hệ thống deposit/withdraw. Thêm nữa, hệ thống deposit/withdraw cũng cần thiết để hỗ trợ cho paymaster.

Tách biệt giữa validation và execution

Interface của ta đang như sau:

contract Wallet {
  function executeOp(UserOperation op);
}

Trên thực tế hiện tại executeOp đang làm 2 nhiệm vụ:

Nếu wallet owner là người thực hiện và trả phí, sẽ không hề có vấn đề gì. Nhưng hiện giờ người thực hiện lại là executor, nó là điều khác biệt rất lớn.

Với thiết kế hiện tại, wallet sẽ phải refund phí giao dịch cho executor trong bất cứ trường hợp nào. Nhưng rõ ràng ta không hề muốn wallet phải trả tiền nếu validation bị fail. Nếu validation fail, điều đó có nghĩa là user operation đã có vấn đề, hoặc nó bị làm sai, hoặc nó bị làm giả, hoặc vì bất cứ lý do gì khác, rõ ràng wallet sẽ không muốn trả phí cho giao dịch này.

Trong trường hợp này, hàm executeOp có thể block operation lại, nhưng dù thực hiện tới đâu thì nó vẫn cứ phải trả phí gas tới đó. Đó là một vấn đề, bởi vì kẻ xấu có thể lợi dụng nó để gửi hàng tá những operations không hợp lệ đến wallet để khiến wallet cạn sạch tiền.

Ngược lại, nếu validation thành công, nhưng execution fail sau đó, thì wallet lúc này cần phải trả phí gas. Điều này cũng giống như EOA gửi một transaction bình thường nhưng sau đó nó bị revert vậy, nó đã pass validation có nghĩa là wallet owner đã đồng ý thực hiện transaction, và sẵn sàng trả phí gas.

Interface hiện tại của wallet không đảm bảo được việc tách biệt giữa validation fail và execution fail, nên ta sẽ tách biệt 2 việc này ra:

contract Wallet {
  function validateOp(UserOperation op);
  function executeOp(UserOperation op);
}

Với việc tách ra 2 hàm này, thì handleOp của Entry Point lúc này sẽ làm những việc sau:

Với sự cải tiến này, wallet sẽ chỉ phải trả phí cho những operation mà nó đồng ý mà thôi.

aa-04

Ta cần chắc chắn rằng một người lạ bất kì không thể trực tiếp gọi hàm executeOp để khiến cho op được thực hiện mà không qua validation. Việc này có thể được giải quyết đơn giản bằng việc giới hạn executeOp chỉ được phép gọi bởi Entry Point mà thôi. Nếu một ví độc hại thực hiện luôn việc execution trong validateOp thì sao? bởi khi này nếu execution fail thì nó sẽ không phải trả phí gas nữa. Ở phần tiếp theo ta sẽ giới thiệu một vài những giới hạn để việc này trên thực tế trở nên hầu như bất khả thi.

Simulation redux

Vấn đề lại tiếp tục nảy sinh với executor.

Giờ đây khi một unauthorized user submit operation lên wallet, operation này rõ ràng sẽ bị fail khi thực hiện validateOp và wallet sẽ không phải trả phí gas. Tuy nhiên executor vẫn sẽ phải trả phí gas cho việc thực hiện validateOp on-chain, và không nhận được bồi thường nào. Kẻ xấu có thể lợi dụng điều này để làm cho executor liên tục thực hiện fail validateOp, và cạn sạch tiền.

Ở phần trước ta đã có nói đến giải pháp simulate transaction dưới local để xem nó có success hay không, và chỉ khi pass simulation, transaction mới được submit lên bằng cách gọi handleOp on-chain. Tuy nhiên khi đó ta đã gặp vấn đề là executor không thể giới hạn quá nhiều những opcodes để tránh trường hợp success tại simulation nhưng fail tại execution.

Nhưng lần này có một chút khác biệt.

Executor không cần thiết phải simulate toàn bộ execution mà trước đó bao gồm cả validateOpexecuteOp, mà giờ đây chỉ cần simulate phần validateOp để biết nó có thể được trả tiền hay không mà thôi. Không giống như executeOp phải thực hiện bất kì action nào của wallet khi tương tác với blockchain, ta có thể giới hạn rất chặt chẽ với validateOp.

Cụ thể, executor sẽ reject op trừ phi validateOp thỏa mãn những giới hạn sau đây:

Mục đích của các giới hạn này là để hạn chế thấp nhất việc validateOp success tại simulation nhưng lại fail tại execution.

Việc không sử dụng OPCODE trong banlist là hiển nhiên dễ hiểu, nhưng phần giới hạn về storage thì có vẻ khá khó hiểu. Ta biết rằng storage có thể thay đổi theo thời gian, gây ra việc success lúc simulation nhưng fail lúc execution, nên ý tưởng của việc giới hạn truy cập storage vào trong chỉ những slot xác định như trên, là làm cho cost của việc bypass simulation (bằng cách kẻ xấu phải thay đổi associated storage giữa lúc simulation và execution) trở nên khó khăn hơn rất nhiều, đến mức không đáng để bỏ công sức ra thực hiện.

Với các giới hạn này, lúc này executor và wallet đều đã được an toàn.

Một lợi ích nữa của storage restriction, là khi ta gọi validateOp từ nhiều wallet khác nhau sẽ hiếm khi bị ảnh hưởng lẫn nhau vì chúng chỉ có thể truy cập vào những storage giới hạn. Điều này vô cùng quan trọng khi ta nói về việc đóng gói các op sau này.

Cải tiến tiếp theo: trả phí gas trực tiếp từ wallet

Hiện tại trước khi thực hiện user operation, wallet cần phải thực hiện deposit ETH vào trong Entry Point. Nhưng EOA thông thường chẳng phải vẫn tự trả phí gas hay sao? tại sao ta lại không làm giống như vậy với smart contract account?

Ta có thể làm được như vậy, vì giờ đây ta đã tách biệt rõ ràng validation và execution, Entry Point có thể yêu cầu wallet gửi một lượng ETH cho phần op validation, hoặc op sẽ bị reject.

Ta sẽ update hàm validateOp để Entry Point có thể yêu cầu fund và có quyền reject nếu validateOp không trả lượng ETH như đã yêu cầu.

contract Wallet {
  function validateOp(UserOperation op, uint256 requiredPayment);
  function executeOp(UserOperation op);
}

Vì tại validation time thì ta sẽ không thể biết được chính xác lượng gas sẽ được sử dụng tại execution, nên Entry Point sẽ yêu cầu lượng gas max dựa trên trường gas bên trong user operation. Tại cuối giao dịch, ta sẽ muốn trả lại lượng gas không sử dụng hết cho wallet.

Có một điểm đáng lưu ý ở đây, khi viết smart contract, ta không luôn chắc chắn có thể gửi ETH một cách an toàn tới một smart contract. Vì nó có thể kích hoạt những code độc hại như re-entrancy, hay gây fail transaction, hay contract từ chối nhận ETH, etc… Do đó ta sẽ không trực tiếp gửi ETH lại cho wallet.

Thay vì thế, ta vẫn hold nó trong Entry Point và cho phép smart contract có thể rút tiền về bất cứ khi nào cần. Đây chính là pull-payment pattern.

Do đó chính xác những gì chúng ta sẽ làm là gửi lượng gas cho validateOp bằng deposit và cho phép wallet có thể rút tiền về thông qua withdrawTo. Ta thấy rằng dù giờ đây wallet trả tiền phí giao dịch như EOA, nhưng ta vẫn cần deposit/withdrawTo trong EntryPoint.

Điều này có nghĩa là wallet gas payment sẽ thực tế đến từ 2 nơi: ETH mà wallet đã deposit vào trong Entry Point, và ETH trong chính wallet.

Entry Point sẽ cố gắng trả phí gas bằng ETH đã được deposit từ trước, và sau đó nếu không đủ nó sẽ yêu cầu phần còn lại khi gọi validateOp.

Executor incentives

Cho đến lúc này, executor vẫn đang làm việc không công, mà còn đối mặt với rất nhiều rủi ro bị quịt tiền phí giao dịch. Ai sẽ sẵn sàng làm công việc này chứ?

Đây là ta cần một hệ thống lợi ích cho executor, bằng cách cho phép wallet owner có thể submit thêm tip cho executor trong user operation:

struct UserOperation {
  // ...
  uint256 maxPriorityFeePerGas;
}

giống như cái tên, maxPriorityFeePerGas là lượng gas mà user sẵn sàng trả để transaction của mình được ưu tiên.

Khi này, executor khi gửi op cho EntryPoint trong handleOp có thể lựa chọn một giá trị maxPriorityFeePerGas nhỏ hơn và đút túi phần chênh lệch.

Entry Point as a Singleton

Chúng ta đã nói về vai trò và cách mà Entry Point hoạt động. Ta có thể chú ý rằng Entry Point được thiết kế tách biệt hoàn toàn không phụ thuộc vào bất cứ wallet hay executor cụ thể nào. Do đó Entry Point hoàn toàn có thể là một singleton xuyên suốt toàn bộ hệ sinh thái. Tất cả mọi wallet và mọi executor đều tương tác với cùng một contract Entry Point duy nhất mà thôi.

Nó có nghĩa là trong user operation, ta cần chỉ rõ địa chỉ wallet mà ta muốn tương tác, để khi tương tác với Entry Point thông qua handleOp, Entry Point biết địa chỉ wallet đến để tiến hành validation và execution.

struct UserOperation {
  // ...
  address sender;
}

No separate EOA recap

Mục tiêu của chúng ta là tạo ra một on-chain smart contract wallet có khả năng trả phí gas cho giao dịch mà không cần wallet owner phải duy trì thêm một EOA riêng rẽ, và ta đã đạt được mục tiêu!

Wallet interface của chúng ta lúc này như sau:

contract Wallet {
  function validateOp(UserOperation op, uint256 requiredPayment);
  function executeOp(UserOperation op);
}

toàn bộ hệ thống có một EntryPoint có interface như sau:

contract EntryPoint {
  function handleOp(UserOperation op);
  function deposit(address wallet) payable;
  function withdrawTo(address destination);
}

Khi wallet owner muốn thực hiện một action, họ sẽ tạo ra một User Operation, off-chain, nhờ một executor thực thi nó giùm mình.

Executor sẽ tiến hành simulate hàm validateOp trong wallet để quyết định xem có nhận user op này hay không. Nếu đồng ý, executor sẽ gửi transaction đến Entry Point bằng cách gọi handleOp.

Entry Point sẽ handle việc validation và execution on-chain, sau đó sẽ refund ETH về cho executor từ deposit fund của wallet trong Entry Point.

Bundling

Với implementation hiện tại, mỗi executor sẽ gửi một transaction để thực hiện một user op. Nhưng giờ đây ta đã có một Entry Point hoàn toàn không phụ thuộc vào một wallet cụ thể nào cả, do đó ta hoàn toàn có thể tiết kiệm được gas bằng cách tập hợp nhiều những user op từ những user khác nhau, sau đó thực hiện tất cả chúng một lần trong một transaction duy nhất mà thôi.

Bằng việc này, ta có thể tiết kiệm được rất nhiều bằng cách không phải lặp đi lặp lại việc trả một lượng fixed 21,000 gas để gửi mỗi giao dịch, hơn thế nữa còn có thể giảm được phí khi thực hiện cold storage accesses (truy cập cùng một storage ở lần sau sẽ rẻ hơn truy cập nó ở lần đầu).

Ta sẽ update code một chút từ:

contract EntryPoint {
  function handleOp(UserOperation op);

  // ...
}

sang

contract EntryPoint {
  function handleOps(UserOperation[] ops);

  // ...
}

Mô hình của chúng ta lúc này sẽ trông như thế này:

aa-05

Hàm handleOps mới sẽ thực hiện như sau:

Có một điều đáng chú ý ở đây là ta sẽ thực hiện một loạt tất cả các bước validation, xong hết sau đó ta mới tiến hành thực hiện execution, thay vì ta thực hiện từng cặp validation-execution liên tiếp nhau.

Điều này rất quan trọng cho việc thực hiện simulation.

Vì nếu ta thực hiện theo từng cặp validation-execution, việc execution trước có thể ảnh hưởng đến storage mà validation sau truy cập, việc này có thể dẫn đến trường hợp như lúc trước ta đã bàn là success khi simulate validation, nhưng lại fail khi thực hiện validation thực tế.

Tương tự, ta cũng muốn tránh (hoặc hạn chế tối đa) luôn cả trường hợp mà validation của op này có liên quan tới validation của op khác trong cùng một bundle.

Vấn đề này có thể được giải quyết bằng cách không lưu trữ nhiều op của cùng một wallet trong cùng một bundle. Khi này do các giới hạn về storage ta đã nói ở bên trên, truy cập storage trong op này sẽ không bị xung đột với truy cập storage trong op khác.

Một lợi ích tuyệt vời khác ở đây là executor có thể có một nguồn income mới!

Executor có thể có cơ hội để kiếm thêm từ Maximal Extractable Value (MEV) bằng cách sắp xếp các user op trong một bundle (thậm chí chèn cả op của chính mình vào) theo một cách có thể sinh ra lợi nhuận.

Giờ đây khi xuất hiện việc đóng gói các user op, ta sẽ dừng việc gọi những người thực hiện giao dịch là executor nữa, mà ta sẽ gọi họ bằng cái tên thật sự được sử dụng trong định nghĩa của ERC-4337 là bundler - người đóng gói.

Bundlers as network participants

Trong thiết kế hiện tại của chúng ta, các wallet owner sẽ submit các user operation đến các bundler để hi vọng các bundler sẽ đóng gói operation của họ vào trong một bundle. Việc này giống với transaction thông thường, nơi mà EOA submit các transaction đến các miner, hay block builder, và hi vọng họ sẽ đóng gói transaction của mình vào trong một block. Nên với cùng một kiến trúc, ta hoàn toàn có thể có những lợi ích tương tự cho những thành phần khác nhau tham gia network.

Cũng giống như việc các node thông thường lưu trữ các pending transaction trong một nơi gọi là mempool và sau đó broadcast chúng đến các node khác, các bundler cũng có thể lưu trữ các validated user operation bên trong một mempool và sau đó broadcast cho những bundler khác. Các bundler có thể validate user op trước khi chia sẻ chúng cho những bundler khác, tiết kiệm thời gian cho bundler khác đỡ phải chạy validation.

Một bundler cũng hoàn toàn có thể là một block builder; khi là một, họ hoàn toàn có thể lựa chọn block để đưa bundle của họ vào, do đó có thể giảm thiểu tối đa, hoặc thậm chí triệt tiêu khả năng transaction bị fail trong execution sau khi đã success trong validation.

Và như bên trên ta đã nói, bundlers và block builder hoàn toàn có thể có thêm lợi ích bằng MEV.

Tham khảo