設計 Account 合約的注意事項
imToken Labs
0x347c
January 25th, 2024

寫給開發者的 Account Abstraction(一):設計 Account 合約的注意事項

  • 作者:Nic @ imToken Labs

  • 校對:Members at imToken Labs

  • 封面來源:Image by alecfavale on Unsplash

  • 目標讀者:區塊鏈開發者

  • 先備知識:

    • 熟悉智能合約開發

    • 知道抽象帳戶(Account Abstraction,以下簡稱 AA)的目的及帶來的好處

    • [Optional] 知道 4337 的 AA 設計與原生 AA(Native AA)設計的不同


在 AA 中,每個使用者的帳戶將不再是 EOA,而是一個智能合約 – Account 合約。這篇文章將會介紹 Account 合約所需具備的核心功能,以及開發者在設計 Account 合約時該考量什麼、注意什麼、避開哪些誤區。介紹將會以 4337 的 AA 設計為主,但也會包含原生 AA 以及 4337 AA 與原生 AA 的差異。

以下介紹會分成五個段落:

  1. 快速複習 AA 的概念以及 Account 合約

  2. 介紹一個 Account 合約所需具備的基本功能,有了這些基本功能使用者才能順利且安全地執行交易

  3. 介紹當開發者要為 Account 合約設計更進階的功能時,在設計上需要注意的地方

  4. 介紹 Account 合約設計在安全性上的考量

  5. 介紹該如何 Debug 4337 交易


Recap: AA 與 Account 合約

Account 合約需要實作必要的功能

在 AA 的世界中,所有使用者在鏈上的身份不再是一個 EOA、一把私鑰,而是一個智能合約 – Account 合約。Account 合約必須要實作基本必要的功能,例如「驗證一筆交易是否經過使用者授權」、「執行交易」,以及「支付手續費」等等,如此使用者才能順利且安全地使用自己的帳戶。

重要功能只能由特定角色觸發

而 Account 合約的重要功能肯定不是任何人都可以來觸發呼叫,而是必須只能由特定的角色來觸發:在原生 AA 的設計(例如 StarkNet 或 zkSync)中是由「是系統合約」去觸發;在非原生 AA 的設計中則是由「Entrypoint 合約」來觸發。例如 4337 官方範例 Account 合約中的 _requireFromEntryPointlink),或是 zkSync 官方範例合約中的 ignoreNonBootloaderlink)。

註:原生 AA 例如 zkSync 中負責觸發 Account 合約的「系統合約」是一個叫 Bootloader 的合約

兩階段:先「驗證」,再「執行」

因為 Account 合約是完全聽(原生 AA 的)系統合約或是(4337 的)Entrpoint 合約的指令,所以系統合約或是 Entrypoint 合約都必須要遵循「先驗證再執行」的步驟,不能跳過驗證直接觸發 Account 合約進行執行的動作,否則就等於能不經使用者同意操控他的帳戶,因此系統合約或 Entrypoint 合約的安全性至關重要。

在原生 AA 或是 4337 中,Account 都必須要實作固定的驗證函式來讓系統合約或是 Entrypoint 合約觸發進行驗證,在原生 AA 例如 zkSync 中 Account 必須實作 validateTransaction 函式(link),而在 4337 中 Account 必須實作 validateUserOp 函式(link)。

4337 的交易:UserOperation

因為 4337 不是原生 AA,所以為了和 Ethereum 交易做區隔,所以將 4337 AA 所使用的交易格式稱作 UserOperation,簡稱 userOpuserOp 格式和 Ethereum 交易格式其實差不多,包含基本的 tocalldatagasPrice 等等,其他些微不太一樣的地方以下馬上就會介紹到。

接下來將介紹 Account 合約所需具備的核心功能。


Account 合約所需具備的核心功能

1. 支付手續費

最基本的就是要能為使用者的交易支付手續費。原本使用 EOA 時使用者需要向 EOA 地址充值 ETH,在 AA 中則是向自己的 Account 合約充值。在 4337 中 Account 合約其實必須要把 ETH 儲值到 Entrypoint 合約中,而在原生 AA 中則不需要特別儲值到某個合約,而是能自動從 Account 身上扣款。

儲值到 Entrypoint 合約

Account 可以透過直接轉 ETH 給 Entrypoint(link)的方式,也可以觸發 Entrypoint 的 depositTo 函式(link)來進行儲值。

如果 Entrypoint 合約來觸發 validateUserOp 進行驗證後,發現 Account 合約沒有補足它在 Entrypoint 上的儲值時,就會導致驗證失敗。所以 validateUserOp 函式裡記得要補足交易所需的 ETH,但 Account 要怎麼知道該儲值多少 ETH?

需補足的 ETH 金額: missingAccountFunds

validateUserOp 函式的三個參數分別是:userOpuserOpHashmissingAccountFunds,前面兩個是 userOp 內容及其 Hash 值,missingAccountFunds 則是 Entrypoint 合約通知 Account 它需要補足的 ETH 金額。如果 missingAccountFunds0,那就表示 Account 在 Entrypoint 的餘額還足以支付手續費;如果 missingAccountFunds 不為 0,那 Account 就要在 validateUserOp 中轉至少 missingAccountFunds 數量的 ETH 給 Entrypoint 合約,例如 4337 的官方範例

以 ERC20 支付手續費

AA 可以允許使用者用 ERC20 代幣來支付手續費,但需要藉由一個中間人 – Paymaster。Paymaster 是一個合約,作為中間人它會向 Account 合約收取 ERC20 代幣,並向 Entrypoint 合約支付 ETH。而 Paymaster 是任何人都可以擔任的,它並沒有規定要如何收取手續費,Account 合約可以預先 approve 一定金額的 ERC20 給 Paymaster 合約、在 validateUserOp 裡支付,或是在執行階段再支付也可以,或甚至 Paymaster 不收取任何手續費也可以,端看 Paymaster 的開發者決定怎麼實作。

下一篇文章會有關於 Paymaster 更完整的介紹。

2. 驗證授權

在 AA 中開發者可以實作各式各樣的驗證方式,可以用和 Ethereum 一樣的簽章演算法(ecrecover)、用手機的安全晶片進行簽名(Passkey)、用 Email 登入及簽名(Social Login),或是用零知識證明驗證授權也可以(像是 zkEmail)。這些驗證邏輯都要實作在 validateUserOp 函式中,透過 userOpHash 參數及 userOp 參數裡的 signature 值來驗證,例如 4337 官方範例的 ecrecover

如果驗證通過,則 validateUserOpreturn 0 給 Entrypoint 合約,代表驗證通過;如果驗證失敗,則要 return 1 給 Entrypoint,代表驗證失敗link)。如果 validateUserOp 裡驗證失敗,回傳值卻寫錯寫成 return 0,那就會導致「任何人都可以通過驗證並執行你的 Account」!

3. 部署 Account 合約

因為 Account 是一個合約,所以合約必須要先經過部署才能執行,不像原本 EOA 只要有了私鑰就可以執行。而相比於原生 AA,4337 的一個優點是可以在使用者執行第一筆交易時才在同一筆交易內順便部署 Account 合約,如此使用者就不需要先送「第一筆專門部署 Account 合約的交易」然後才能送第二筆「去執行 Account 合約的交易」。

如果 Account 合約還沒部署,則使用者的第一筆交易(userOp)的 initcode 參數必須要填入部署所需的資料。initcode 的前面 20 bytes 必須是用來部署 Account 合約的 Factory 合約地址,後面接的是要呼叫 Factory 合約的哪個函式以及所需的參數。以我們自己範例的 Factory 合約 SignatureAccountFactory 合約為例,這個 Factory 合約裡負責部署 Account 合約(SignatureAccount 合約)的函式是 createAccount,參數是 saltownerlink),所以 Account 合約的使用者在他的第一筆 userOpinitcode 欄位要填入的就是「20 bytes 的 SignatureAccountFactory 合約地址」以及「createAccount(salt, owner) 編碼過後的資料」(link)。

使用者在 userOp 的 initcode 欄位指定部署資訊,Entrypoint 再按照部署資訊去請求 Factory 部署
使用者在 userOp 的 initcode 欄位指定部署資訊,Entrypoint 再按照部署資訊去請求 Factory 部署

註 1:(1) SignatureAccountFactory 合約要先被部署。(2) salt 用來重複部署同樣一份合約,可以填任意值,但一樣的值不能重複使用。(3) owner 是使用者的 EOA 地址,EOA 的私鑰用來簽名授權交易。

註 2:Factory 會提供函式讓使用者查詢 Account 合約會被部署到的地址,例如 SignatureAccountFactory 合約裡的 getAccountAddress 函式(link)。

部署失敗的情況

  • 重複部署:如果 Account 合約已經部署但 initcode 又有帶入參數的話,那 userOp 就會失敗

  • 忘記部署:如果使用者沒有部署好 Account 合約,initcode 也沒有帶入部署的參數的話,那 userOp 就會失敗

  • 部署失敗:如果 Factory 合約沒有部署的話或 initcode 部署參數帶錯導致 Factory 部署失敗的話,那 userOp 就會失敗

  • 部署到錯的地址:如果部署成功但部署的 Account 合約地址和 userOp 裡填的 sender 參數不一樣的話,表示部署到錯的地址,sender 這個地址還是沒有合約,那 userOp 就會失敗

以上是 Account 合約必須具備的三個功能:(1) 支付手續費、(2) 驗證授權,以及 (3) 部署。實作好這三個功能後使用者就能順利地享受和以前 EOA 一樣的使用體驗了!例如 zkSync 的 DefaultAccount 就是為了服務那些較熟悉或習慣使用 EOA 的使用者,方便他們快速上手所打造的一個具備基本功能的預設 Account 合約。當然原生 AA 在部署這一部分因為要先有一筆交易去部署 Account 合約,所以使用體驗會和 EOA 不太一樣。不過這筆部署的操作是可以被隱藏起來的,例如錢包或 dApp 可以先代墊手續費替使用者部署好 Account 合約,等到使用者執行第一筆交易時再順便向使用者請款。

接下來將會介紹當開發者要為 Account 合約加入更多更複雜的功能時,在設計上需要注意的地方。


設計 Account 合約功能需要注意的地方

4337 規則的限制

4337 在交易驗證階段訂立了一些規則,以避免攻擊者製作惡意的 userOp 來攻擊負責送交易上鏈的 Bundler,讓 Bundler 在鏈下模擬時以為 userOp 會成功但實際上鏈執行時卻失敗,導致 Bundler 白白支付手續費,因此開發者在實作 validateUserOp 函式時要記得避免違反 4337 的規則,否則 Bundler 有可能會拒絕你 Account 合約的 userOp

註:詳細的規則可以參考 4337 的 EIP,這些規則有可能在未來放鬆或變嚴。不過這些規則是鏈下的規範,並非鏈上的限制,所以 Account 使用者其實可以自己當 Bundler,送一個違反規則的 userOp 上鏈執行

validateUserOp 中不能讀取任何地址(包含 Account 合約本身)的 ETH 餘額

像是 OpenZepplin 的 ERC20 轉帳套件 SafeERC20 裡的 safeTransfer,其底層使用 Address 套件進行函式呼叫,而其函式呼叫會去讀取帳戶自身的 ETH 餘額進行檢查,而這在 4337 規則中是不允許的,因此沒辦法在 validateUserOp 中使用 OpenZepplin 的 ERC20 轉帳套件。

註:另外像是 tx.origintimestamp 或是 gasleft 這幾個值也被禁止,開發者要注意。詳細被禁止的 Opcode 可以參考 EIP

validateUserOp 中對 Account 合約以外的 Storage 存取限制

4337 規則禁止存取 Account 合約本身以外的 Storage,除非是和 Account 地址本身有關的 mapping,例如 ERC20 合約裡用來記錄每個地址餘額的 mapping(address => uint256) balanceOf。但這個 mapping 也不能是 nested mapping,例如 mapping(address => mapping(uint256 => MyStruct)

如果要將 Account 合約做模組化的設計、允許切換任意的客製化驗證機制合約,則客製化的驗證機制合約就不能使用 nested mapping,或是得改成將資料存在 Account 合約上。在後面的模組化設計考量中會更深入介紹客製化驗證機制需要注意的地方。

validateUserOp 中不能呼叫可升級合約

可升級合約像是 EIP-1967 因為把 implementation 地址存在 Storage 裡固定的位址,而 4337 規則禁止存取 Account 合約本身以外的 Storage(除非是和 Account 地址本身有關的 mapping),因此在 validateUserOp 無法去讀取或操作可升級合約像是 USDC 合約,也就無法進行 USDC 轉帳。

註:目前有提案透過建立白名單的方式允許存取,或是等 4337 有一天放寬限制,在這之前使用者或任何願意承擔風險的人還是可以自己送出 userOp 到鏈上執行。

當有多個 userOp,Entrypoint 合約會先「驗證」過全部的 userOp,接著才「執行」全部的 userOp

當一個 Bundler 同時帶上多個 userOp 到鏈上來執行,4337 為了避免「排在前面的 userOp」在「執行」階段故意去影響「後面 userOp」的「驗證」條件,因此將 Entrypoint 合約設計成先「驗證」過全部的 userOp,接著才會進入「執行」階段,而不是一個 userOp 「驗證」並「執行」完再換下一個。

所以開發者如果預期會有多筆 userOp 合併一起送上鏈,且它們之間有相依性時,請記得它們的「驗證」和「執行」還是分開來的,排序在前面的 userOp 沒辦法在「執行」階段影響後面 userOp 的「驗證」。

一個 Batch 裡的 userOp 會先全部「驗證」完,才會進到「執行」階段
一個 Batch 裡的 userOp 會先全部「驗證」完,才會進到「執行」階段

4337 Nonce 設計

4337 Entrypoint 合約裡會為每個地址維護一個 nonce 值,稱為 nonceSequenceNumberlink),用來防止 userOp 被重放攻擊(Replay Attack)。4337 將 256 bit 的 nonceSequenceNumber 切成 192 bit 的 key 與 64 bit 的 nonce

|          nonceSequenceNumber          |
|---------------------------------------|
|           key          |     nonce    |
|------------------------|--------------|
|         192 bit        |    64 bit    |  

當 Entrypoint 要驗證某個 userOpnonce 值時,Entrypoint 會先用 userOp.nonce 前面 192 bit 的 key 值搭配 Account 合約地址來找到儲存的 nonce 值然後再與 userOp.nonce 剩下的 64 bit 的 nonce 值做比對(link)。

註 1:如果 Account 合約沒有管理多組 nonce 值的需求的話,192 bit 的 key 值就都使用 0 即可。不過要記得 nonce 不能超過 64 bit,否則不會是同樣一組的 nonce 值。

註 2:每個 userOp 如果成功執行完,nonce 值都會被往上加一,即便這個 userOp 只有單純部署(有填 initcode)、沒有執行任何操作(沒有填 calldata)。

4337 與原生 AA 的差異

原生 AA「執行」階段的進入點是固定的

4337 及原生 AA 在「驗證」階段的進入點都是固定的,例如 4337 是 validateUserOplink)、zkSync 是 validateTransactionlink)、StarkNet 是 __validate__link)等等,但「執行」階段就不一樣了。

在 4337 中使用者可以自己指定「執行」階段的進入點,例如使用者可以要求 Entrypoint 在驗證完後呼叫 Account 合約上的 execute 函式或是 change_owner 函式等等(link1link2),但在原生 AA 中「執行」階段的進入點是固定的,例如 zkSync 是 executeTransactionlink)、StarkNet 是 __execute__link),原生 AA 在驗證完後固定就是呼叫這個函式,使用者不能要求原生 AA 呼叫其他函式。4337 的這個彈性讓其 Account 合約的執行流程與設計變得相對簡潔。

對原生 AA 的 Account 合約來說,不管使用者是要執行合約本身的功能(例如 change_owner)或是對外呼叫(例如透過 execute 去 Uniswap 做兌換),它都要先進到固定的「執行」階段進入點(例如 __execute__),然後在這個 __execute__ 函式裡再去執行指令。像是 Argent Account 合約裡的 __execute__ 函式便是單純執行使用者指定的 calllink),這個 call 可能是呼叫合約自己,也可能是對外呼叫。不過這種設計底下 Account 合約裡的函式就要搭配像是 assert_only_self 這樣的權限檢查來確保只有來自 Account 合約自己的呼叫才是可信的(link),而不是像 4337 一樣是檢查呼叫者是 Entrypoint 合約。

原生 AA 的執行階段進入點都是固定的,如果要執行 Account 本身的功能,就要透過呼叫自己的方式
原生 AA 的執行階段進入點都是固定的,如果要執行 Account 本身的功能,就要透過呼叫自己的方式
在 _execute_ 函式裡直接執行呼叫,如果呼叫自己就表示是執行 Account 本身功能
在 _execute_ 函式裡直接執行呼叫,如果呼叫自己就表示是執行 Account 本身功能
但 Account 本身功能要檢查的就是呼叫者必須只能是自己
但 Account 本身功能要檢查的就是呼叫者必須只能是自己

開發者在設計 Account 合約時要注意

  • 原生 AA 與非原生 AA 的執行流程不一樣,這會影響你是否要設計一套能兼容原生 AA 與非原生 AA 的架構

  • 以及如果設計原生 AA 的 Account 合約時,Account 合約本身的函式(例如 change_owner)的權限檢查要改成像是 assert_only_self 這樣的檢查

4337 Account 合約在「執行」階段沒有辦法讀取 userOp 相關資訊

在原生 AA 中的「執行」階段,Account 合約可以知道交易的內容以及交易的 Hash 值,例如 zkSync 的 executeTransaction 函式參數本身就有 _txHash 以及完整的交易內容 _transactionlink),StarkNet 則是可以透過系統函式 get_tx_info 去查詢交易內容(link)。但在 4337 中 Account 合約則沒辦法獲得任何 userOp 相關資訊,這可能會影響某些開發者在 Account 合約功能上的設計。

註 1:Account 合約可以在「驗證」階段將 userOp 資訊儲存到 Storage,但記得前面提到過 4337 會先「驗證」過所有 userOp 才「執行」,所以如果 Account 合約剛好有兩筆 userOp 一起打包執行,那第二筆的 userOp 在驗證時就會覆寫掉第一筆 userOp 儲存的資訊。

註 2:4337 Entrypoint 開發者有注意到這個需求,並在尋找可能的解法

原生 AA 的規則限制稍有不同

zkSync 在 Storage 讀取限制上多放寬一條規則(link):Account 合約可以讀取其他合約上 Storage 位址和自己地址一樣的 Storage,例如假設 Bob 的 Account 合約部署在地址 0xabc 上,則在驗證階段他的 Account 合約可以讀取其他合約上 Storage 位址在 0xabc 的資料。未來 zkSync 也可能開放讀取 timestamp 讓交易能具備時效性(link)。

StarkNet 則是不允許 Account 合約在「驗證」階段執行任何對外呼叫(link),也就表示 Account 合約只能讀取自身的 Storage。

Nonce 機制不同

  • 4337 的 Entrypoint nonce 設計會區分 192 bit 的 key 值及 64 bit 的 nonce

  • zkSync 則沒有區分,只能單純遞增,但未來可能會支援更彈性的設計(link

  • StarkNet 也沒有區分,甚至沒有抽象掉 nonce,它和目前 Ethereum 協議一樣為每個地址管理一個固定遞增的 nonce 值(zkSync 是由一個 NonceHolder 合約管理 nonce 值)

設計一個彈性、模組化的 Account 合約

可以更新的驗證邏輯

開發者可以將 Account 合約的驗證邏輯實作在第三方合約(以下簡稱 Module)中,Account 合約在執行 validateUserOp 時就呼叫 Module 來進行驗證,Module 驗證完後再回傳結果。引入 Module 需要注意的地方是:Module 能在驗證過程需要某些資料(例如需要 Owner 地址來驗證簽章是否合法),這些資料要存在哪裡?

  • 如果這些資料存在 Module 合約上,那 Module 就會受 4337 Storage 存取規則限制,例如只能用 mapping 存且不能用 nested mapping 等等

  • 如果資料是儲存在 Account 合約身上,那 Account 合約可能得使用 delegatecall 進行呼叫,但這時就要小心惡意的 Module 是可以修改 Account 合約其他 Storage 資料的,使用者在選擇 Module 時必須要特別注意

    • 如果 Account 合約是用 call 呼叫 Module,那表示 Module 讀取資料時也是用 call(等於是 Account call Module,Module 又再 call Account)。除了多出來的來回 call,開發者也要注意修改 Module 資料的權限,不能允許一個 Module 去修改另一個 Module 保存在 Account 合約上的資料

可以更新的 fallback 函式

fallback 函式通常用來處理合約本身沒有處理到的情況,例如觸發了 Account 合約本身沒有的某個函式,那就會進到 fallback 函式裡。而合約錢包通常都會需要實作 fallback 函式而且可以更新 fallback 裡的邏輯,因為像是 ERC-721 或 ERC-1155 這種代幣會要求如果接收者是合約的話,它要實作特定的函式(例如 onERC721ReceivedonERC1155Received),否則代幣轉帳就會失敗,而因為開發者預期未來還會有新的代幣標準出現,所以會讓 fallback 裡的邏輯可以更新,以便未來能支援新的代幣接收函式(例如 onERC5566Received)。

ERC-6900

另一個使用 fallback 的方式是將它作為非常彈性的可升級合約的核心,這個可升級合約的標準稱為 EIP-2535 Diamond Proxy。而 ERC-6900 這個標準就是將 Diamond Proxy 套用在 4337 Account 合約上,讓 Account 合約變成非常彈性、可以模組化地新增、修改或移除(第三方寫的) Module 的合約。目前 ERC-6900 這個標準正在積極地開發中,但有些人覺得 ERC-6900 加入了很多設計者個人的設計偏好導致整個架構變得非常複雜、沒有那麼彈性,因此提出了 ERC-7579,這個標準力求保持彈性,讓 Account 的開發者有更多的決定權。

另一個需要注意的問題是:ERC-6900 和原生 AA 不兼容。這是因為 ERC-6900 預期呼叫合約的進入點都是透過 fallbackfallback 裡面再按照 msg.data 去判斷該用哪個 Module 的邏輯來執行,但原生 AA 的 Account 合約有固定的進入點,例如 validateTransactionexecuteTransaction__validate____execute__等等,所以在原生 AA 的交易執行流程中是不會觸發 Account 合約的 fallback 的(除非是代幣轉帳觸發 onERC721Received 等等),因此在 4337 按照 ERC-6900 實作的 Account 合約不能直接原封不動地搬到原生 AA 中使用。

ERC-6900 Account 由 fallback 進入,然後再依要執行的不同函式 delegatecall 到不同實作合約
ERC-6900 Account 由 fallback 進入,然後再依要執行的不同函式 delegatecall 到不同實作合約

註:這裡搜集了許多模組化 Account 設計的資源。


設計 Account 合約需要注意的安全性考量

delegatecall

採用模組化設計,在呼叫 Module 合約時盡量避免使用 delegatecall,因為你必須付出額外許多時間去檢查 Module 是否有可能無意間或惡意地去修改重要的 Storage 資料。

Structured Storage

合約的 Storage 規劃盡量採用 Structured Storage 的方式,也就是為不同函式、不同 Module 劃分不同的 Storage 區域,而不是像一般合約預設是由 Storage 位址 0 開始依序新增(即 Unstructured Storage)。Unstructured Storage 在遇到合約彼此之間有複雜的繼承關係時時容易出錯,或至少得像 Safe 一樣將母合約的 Storage 位址統一整理在一個合約裡,方便其他人知道 Safe 合約本身一共宣告了哪些(Unstructured Storage)變數。

多鏈部署

如果開發者預期 Account 合約會部署到多條鏈上,則開發者必須在安全性上做一個選擇:如果要允許任何人都可以部署 Account 合約(即去中心化部署),那使用者就不能遺失初始那一把私鑰;反之,如果預期使用者有可能會遺失初始那一把私鑰,那就只能由特定角色來部署 Account 合約。在解釋為什麼之前,我們先介紹一下「多鏈部署安全性」。

多鏈部署安全性

多鏈部署安全性指的是錢包合約在多鏈環境中,要如何避免攻擊者藉由搶先部署使用者的錢包合約到新的鏈上來獲得其控制權及其資產。例如 Alice 創建了錢包,在 Ethereum 上部署了錢包合約,而她預期她在 Arbitrum 或 Optimism 上的錢包合約地址也是一樣的,所以她就先用這個地址來在 Arbitrum 或 Optimism 上收款,等到要花錢的時候再去部署。這時如果我們沒辦法保證當錢包合約部署在 Arbitrum 或 Optimism 上 Alice 一樣能獲得錢包的控制權,那 Alice 的資產就岌岌可危了。現實中的 Wintermute 就碰上了這個問題

所以為了避免攻擊者透過搶先部署來獲得錢包控制權,我們必須把控制權的資訊(例如 Owner 地址)嵌入到部署資訊中,如此同樣的錢包合約搭配不同的 Owner 就會部署到不同地址,而攻擊者搶先部署也拿不到錢包控制權,因為錢包合約在部署時會直接使用嵌入的地址當作 Owner。 例如 Soul-Wallet 的部署會將包含控制權資訊的 initializer 資料嵌入 Create2 使用的 salt 值中(link1link2)。

註:Safe 則是不在合約本身做任何防護,而是透過 ERC-3770 來幫助使用者識別自己的錢包,並要求 Safe 使用者不要假設「錢包在不同鏈上的地址會是一樣的」(link)。

Trade-off

前面提到為了確保多鏈部署的安全性,會需要將控制權資訊例如 Owner 地址嵌入到部署資訊中,但這就會遇到一開始提到的問題了:開發者得在「是否用中心化部署」及「是否假設使用者會遺失初始私鑰」之間做選擇。

去中心化部署但使用者就不能遺失初始的私鑰:在這個設計當中,任何人都可以(替別人)部署錢包合約。部署地址由使用者的初始私鑰對應的地址所決定(例如將 Owner 地址嵌入 salt 中),且錢包合約在部署時會直接使用嵌入的地址當作 Owner。如此即便攻擊者搶先在其他鏈部署錢包合約到同一個地址,該錢包合約的擁有人一樣是使用者的初始地址。但如果使用者遺失初始私鑰,則等同於失去「他在所有其他鏈上還未部署的錢包合約的控制權」。

中心化部署但使用者可以放心更換私鑰:由一個中心化角色例如錢包商來負責錢包合約的部署,所以不需要擔心被搶先部署。部署地址不受使用者的初始私鑰對應的地址所影響,錢包合約在不同鏈上都可以部署到同一個地址且又能在每次部署時讓使用者自己指定 Owner 地址,如此使用者就不需要一直保管著初始私鑰。但使用者需要相信錢包商,如果錢包商作惡,它可以自行部署並拿走使用者在其他鏈上還未部署的錢包合約的控制權。

理想的解法:ENS

開發者要在「是否用中心化部署」及「是否假設使用者會遺失初始私鑰」之間做選擇並不是個好的解法,理想的解法是如同 Safe 所說,使用者不該假設他的錢包合約在其他鏈上一樣會部署到同一個地址,但要能做到這件事,錢包們得提供一個夠好的使用體驗,讓使用者不需用難記的「地址」來識別錢包,而是用方便好記的「ENS 名稱」來識別錢包。如此即便 Alice 的錢包合約在不同鏈上都部署在不一樣的地址,但這些錢包合約都是「alice.eth」底下的子域名,例如「arbitrum.alice.eth」、「optimism.alice.eth」等等。


Debug 4337 Error

因為 4337 不是原生 AA,所以 Debug userOp 方式不像 Debug Ethereum 交易一樣,而更像是在 Debug 智能合約的執行。

處理 userOp 用的是 Custom Error

4337 Entrypoint 合約裡在處理 userOp 時是使用「Custom Error」(error FailedOp(uint256 opIndex, string reason)),而不是使用「Revert String』(例如 require(condition, "error string"))的方式,因此在鏈下模擬 userOp 執行時,如果模擬的交易執行失敗,節點會給你 Error Message,但因為是 Custom Error 所以 Error Message 裡會是 Entrypoint 定義的 Custom Error 的編碼。例如假設你的 userOpnonce 給錯導致驗證 nonce 失敗,這時 Entrypoint 會觸發 FailedOp Error( revert FailedOp(opIndex, "AA25 invalid account nonce")link),但你在節點給你的 Error Message 裡不會看到 "FailedOp(...)" 這麼明白清楚的字串,而是 0x220266b6000000000...6e6365000000000000,這是什麼?這是 FailedOp 及其 Error 內容的編碼,你可以用 Foundry 的 cast 功能進行解碼:

> cast 4bd 0x220266b600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001a4141323520696e76616c6964206163636f756e74206e6f6e6365000000000000
1) "FailedOp(uint256,string)"
0
AA25 invalid account nonce

上面程式碼中第二行就是 FailedOp 這個 Custom Error 的完整定義,這是 cast 嘗試解碼 0x220266b6 後找到的定義,第三行及第四行則是 cast 按照這個 Custom Error 的定義去解碼 0x220266b6 後面的資料所得到的結果,第三行的 0 代表 opIndex(該 userOp 在這一批 userOp 中的排序位置),第四行的 AA25 invalid account nonce 則是錯誤訊息 reasonAA25 這個錯誤在這裡觸發。

當你瀏覽過 Entrypoint 合約,或是用 "AA 這個關鍵字在裡面搜尋就會發現 Entrypoint 針對不同錯誤情況都使用 FailedOp 並搭配不同的 reason,例如當 Account 合約沒有支付足夠的手續費時會觸發 AA21 錯誤:revert FailedOp(opIndex, "AA21 didn't pay prefund")link)。

因此當你發現 userOp 執行失敗時,你得搭配解碼工具例如 cast 去解碼知道是哪一個 Custom Error 以及它的內容、錯誤訊息等等。 除了 FailedOp,Entrypoint 還定義了數個 Custom Error(link)。

註:或如果你是使用 Foundry 進行開發,並在測試裡預期 userOp 會觸發特定的 FailedOp 錯誤時,你得使用 vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, ...)) 這樣的寫法。

如果 userOp 真的在鏈上執行失敗

前面一段講的都是在鏈下模擬 userOp 執行失敗時的 Debug 方式,但如果 userOp 不幸送到鏈上卻執行失敗呢?Etherscan 目前已經有支援顯示 Custom Error,所以你可以透過 Etherscan 的交易資訊主頁面看到錯誤訊息:

這筆 userOp 的 Account 合約已經部署,但 userOp 裡還是有填 initcode 所以觸發 AA10 錯誤(link: https://github.com/eth-infinitism/account-abstraction/blob/e775dc080556084276f380f185aad96d709f9ab8/contracts/core/EntryPoint.sol#L375 )
這筆 userOp 的 Account 合約已經部署,但 userOp 裡還是有填 initcode 所以觸發 AA10 錯誤(link: https://github.com/eth-infinitism/account-abstraction/blob/e775dc080556084276f380f185aad96d709f9ab8/contracts/core/EntryPoint.sol#L375 )

但如果 userOp 是在「執行」階段才執行失敗的話,這時 Bundler 的交易是不會失敗的,Account 合約還是要照付手續費給 Bundler。因此這種情況你不會在交易資訊主頁面看到執行失敗的錯誤訊息,而是要去查詢交易的 Log,因為 Entrypoint 在這種情況會成功執行並透過 UserOperationEvent 這個 Event 去公布這筆 userOp 執行的結果(link):

(Bundler 交易的)交易資訊主頁面裡的 Status 顯示的是交易是執行成功的
(Bundler 交易的)交易資訊主頁面裡的 Status 顯示的是交易是執行成功的
userOp 執行失敗的話,會在 Bundler 交易的 Log 頁面(https://etherscan.io/tx/0xbaaa0e299aad9489e83e26f52b68d1d3266253dfe9ee0a08b8424d8ff9f45ae1#eventlog )裡找到該筆 userOp 的 UserOperationEvent Event,裡面的 success 會告知這筆 userOp 是否執行成功
userOp 執行失敗的話,會在 Bundler 交易的 Log 頁面(https://etherscan.io/tx/0xbaaa0e299aad9489e83e26f52b68d1d3266253dfe9ee0a08b8424d8ff9f45ae1#eventlog )裡找到該筆 userOp 的 UserOperationEvent Event,裡面的 success 會告知這筆 userOp 是否執行成功
如果 userOp 執行成功,就會在它的 UserOperationEvent Event 中看到 success 是 True
如果 userOp 執行成功,就會在它的 UserOperationEvent Event 中看到 success 是 True

另外像是 4337 專門的瀏覽器,例如 Blocknative 的 4337 瀏覽器,裡面顯示的錯誤目前還是非常不清楚:

左邊是「驗證」階段失敗的 userOp,右邊是「執行」階段失敗的 userOp。🤷‍♂️
左邊是「驗證」階段失敗的 userOp,右邊是「執行」階段失敗的 userOp。🤷‍♂️

總結與重點

  • Account 合約最基本需要具備的功能:支付手續費、驗證使用者授權以及部署合約

  • 4337 有一些 Opcode 及 Storage 存取限制,在驗證函式裡要避免違反規則導致 userOp 被 Bundler 拒收

  • 但這些限制只是軟性限制,且可能會隨著時間修改的限制。開發者或使用者還是可以自己當 Bundler 送違反限制的 userOp 上鏈,所以記得不要為了這些限制而犧牲潛在的優秀設計

  • 4337 在限制、Nonce 機制與根本的架構上有許多不一樣的地方,如果要設計一個兼容 4337 與原生 AA 的錢包務必要知道這些差異

  • 尤其是模組化的設計,著名的例子像是 ERC-6900,它和原生 AA 就不兼容,因為 ERC-6900「執行」階段的進入點是 fallback 而原生 AA「執行」階段的進入點則是固定的函式(例如 __execute__

  • 安全性上,盡量避免使用 delegatecall、使用 Structured Storage,以及如果要部署到多鏈的話,必須要防止部署可以因為被搶跑而喪失控制權,但也要知道這樣的設計代表使用者不能忘記初始私鑰

  • Debug 4337 錯誤目前還非常麻煩,開發者必須要解碼 Custom Error 才能知道是什麼錯誤。userOp 在鏈上失敗也因為分成「驗證」與「執行」兩階段而有不同的錯誤呈現方式,增加使用者或開發者在 Debug 上的難度


參考資料與推薦延伸閱讀

2023 ETHTaipei AA Workshop by imToken

2023 Dapp Learning AA Introduction by imToken

Subscribe to imToken Labs
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from imToken Labs

Skeleton

Skeleton

Skeleton