作者: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 的差異。
以下介紹會分成五個段落:
快速複習 AA 的概念以及 Account 合約
介紹一個 Account 合約所需具備的基本功能,有了這些基本功能使用者才能順利且安全地執行交易
介紹當開發者要為 Account 合約設計更進階的功能時,在設計上需要注意的地方
介紹 Account 合約設計在安全性上的考量
介紹該如何 Debug 4337 交易
在 AA 的世界中,所有使用者在鏈上的身份不再是一個 EOA、一把私鑰,而是一個智能合約 – Account 合約。Account 合約必須要實作基本必要的功能,例如「驗證一筆交易是否經過使用者授權」、「執行交易」,以及「支付手續費」等等,如此使用者才能順利且安全地使用自己的帳戶。
而 Account 合約的重要功能肯定不是任何人都可以來觸發呼叫,而是必須只能由特定的角色來觸發:在原生 AA 的設計(例如 StarkNet 或 zkSync)中是由「是系統合約」去觸發;在非原生 AA 的設計中則是由「Entrypoint 合約」來觸發。例如 4337 官方範例 Account 合約中的 _requireFromEntryPoint
(link),或是 zkSync 官方範例合約中的 ignoreNonBootloader
(link)。
註:原生 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 不是原生 AA,所以為了和 Ethereum 交易做區隔,所以將 4337 AA 所使用的交易格式稱作 UserOperation
,簡稱 userOp
。userOp
格式和 Ethereum 交易格式其實差不多,包含基本的 to
、calldata
、gasPrice
等等,其他些微不太一樣的地方以下馬上就會介紹到。
接下來將介紹 Account 合約所需具備的核心功能。
最基本的就是要能為使用者的交易支付手續費。原本使用 EOA 時使用者需要向 EOA 地址充值 ETH,在 AA 中則是向自己的 Account 合約充值。在 4337 中 Account 合約其實必須要把 ETH 儲值到 Entrypoint 合約中,而在原生 AA 中則不需要特別儲值到某個合約,而是能自動從 Account 身上扣款。
Account 可以透過直接轉 ETH 給 Entrypoint(link)的方式,也可以觸發 Entrypoint 的 depositTo
函式(link)來進行儲值。
如果 Entrypoint 合約來觸發 validateUserOp
進行驗證後,發現 Account 合約沒有補足它在 Entrypoint 上的儲值時,就會導致驗證失敗。所以 validateUserOp
函式裡記得要補足交易所需的 ETH,但 Account 要怎麼知道該儲值多少 ETH?
missingAccountFunds
validateUserOp
函式的三個參數分別是:userOp
、userOpHash
及 missingAccountFunds
,前面兩個是 userOp
內容及其 Hash 值,missingAccountFunds
則是 Entrypoint 合約通知 Account 它需要補足的 ETH 金額。如果 missingAccountFunds
為 0
,那就表示 Account 在 Entrypoint 的餘額還足以支付手續費;如果 missingAccountFunds
不為 0
,那 Account 就要在 validateUserOp
中轉至少 missingAccountFunds
數量的 ETH 給 Entrypoint 合約,例如 4337 的官方範例。
AA 可以允許使用者用 ERC20 代幣來支付手續費,但需要藉由一個中間人 – Paymaster。Paymaster 是一個合約,作為中間人它會向 Account 合約收取 ERC20 代幣,並向 Entrypoint 合約支付 ETH。而 Paymaster 是任何人都可以擔任的,它並沒有規定要如何收取手續費,Account 合約可以預先 approve
一定金額的 ERC20 給 Paymaster 合約、在 validateUserOp
裡支付,或是在執行階段再支付也可以,或甚至 Paymaster 不收取任何手續費也可以,端看 Paymaster 的開發者決定怎麼實作。
下一篇文章會有關於 Paymaster 更完整的介紹。
在 AA 中開發者可以實作各式各樣的驗證方式,可以用和 Ethereum 一樣的簽章演算法(ecrecover
)、用手機的安全晶片進行簽名(Passkey)、用 Email 登入及簽名(Social Login),或是用零知識證明驗證授權也可以(像是 zkEmail)。這些驗證邏輯都要實作在 validateUserOp
函式中,透過 userOpHash
參數及 userOp
參數裡的 signature
值來驗證,例如 4337 官方範例的 ecrecover。
如果驗證通過,則 validateUserOp
要 return 0
給 Entrypoint 合約,代表驗證通過;如果驗證失敗,則要 return 1
給 Entrypoint,代表驗證失敗(link)。如果 validateUserOp
裡驗證失敗,回傳值卻寫錯寫成 return 0
,那就會導致「任何人都可以通過驗證並執行你的 Account」!
因為 Account 是一個合約,所以合約必須要先經過部署才能執行,不像原本 EOA 只要有了私鑰就可以執行。而相比於原生 AA,4337 的一個優點是可以在使用者執行第一筆交易時才在同一筆交易內順便部署 Account 合約,如此使用者就不需要先送「第一筆專門部署 Account 合約的交易」然後才能送第二筆「去執行 Account 合約的交易」。
如果 Account 合約還沒部署,則使用者的第一筆交易(userOp
)的 initcode
參數必須要填入部署所需的資料。initcode
的前面 20 bytes 必須是用來部署 Account 合約的 Factory 合約地址,後面接的是要呼叫 Factory 合約的哪個函式以及所需的參數。以我們自己範例的 Factory 合約 SignatureAccountFactory 合約為例,這個 Factory 合約裡負責部署 Account 合約(SignatureAccount 合約)的函式是 createAccount
,參數是 salt
及 owner
(link),所以 Account 合約的使用者在他的第一筆 userOp
的 initcode
欄位要填入的就是「20 bytes 的 SignatureAccountFactory 合約地址」以及「createAccount(salt, owner)
編碼過後的資料」(link)。
註 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 合約加入更多更複雜的功能時,在設計上需要注意的地方。
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.origin
、timestamp
或是 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
到鏈上執行。
當一個 Bundler 同時帶上多個 userOp
到鏈上來執行,4337 為了避免「排在前面的 userOp
」在「執行」階段故意去影響「後面 userOp
」的「驗證」條件,因此將 Entrypoint 合約設計成先「驗證」過全部的 userOp
,接著才會進入「執行」階段,而不是一個 userOp
「驗證」並「執行」完再換下一個。
所以開發者如果預期會有多筆 userOp
合併一起送上鏈,且它們之間有相依性時,請記得它們的「驗證」和「執行」還是分開來的,排序在前面的 userOp
沒辦法在「執行」階段影響後面 userOp
的「驗證」。
4337 Entrypoint 合約裡會為每個地址維護一個 nonce 值,稱為 nonceSequenceNumber
(link),用來防止 userOp 被重放攻擊(Replay Attack)。4337 將 256 bit 的 nonceSequenceNumber
切成 192 bit 的 key
與 64 bit 的 nonce
。
| nonceSequenceNumber |
|---------------------------------------|
| key | nonce |
|------------------------|--------------|
| 192 bit | 64 bit |
當 Entrypoint 要驗證某個 userOp
的 nonce
值時,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 在「驗證」階段的進入點都是固定的,例如 4337 是 validateUserOp
(link)、zkSync 是 validateTransaction
(link)、StarkNet 是 __validate__
(link)等等,但「執行」階段就不一樣了。
在 4337 中使用者可以自己指定「執行」階段的進入點,例如使用者可以要求 Entrypoint 在驗證完後呼叫 Account 合約上的 execute
函式或是 change_owner
函式等等(link1、link2),但在原生 AA 中「執行」階段的進入點是固定的,例如 zkSync 是 executeTransaction
(link)、StarkNet 是 __execute__
(link),原生 AA 在驗證完後固定就是呼叫這個函式,使用者不能要求原生 AA 呼叫其他函式。4337 的這個彈性讓其 Account 合約的執行流程與設計變得相對簡潔。
對原生 AA 的 Account 合約來說,不管使用者是要執行合約本身的功能(例如 change_owner
)或是對外呼叫(例如透過 execute
去 Uniswap 做兌換),它都要先進到固定的「執行」階段進入點(例如 __execute__
),然後在這個 __execute__
函式裡再去執行指令。像是 Argent Account 合約裡的 __execute__
函式便是單純執行使用者指定的 call
(link),這個 call
可能是呼叫合約自己,也可能是對外呼叫。不過這種設計底下 Account 合約裡的函式就要搭配像是 assert_only_self
這樣的權限檢查來確保只有來自 Account 合約自己的呼叫才是可信的(link),而不是像 4337 一樣是檢查呼叫者是 Entrypoint 合約。
開發者在設計 Account 合約時要注意
原生 AA 與非原生 AA 的執行流程不一樣,這會影響你是否要設計一套能兼容原生 AA 與非原生 AA 的架構
以及如果設計原生 AA 的 Account 合約時,Account 合約本身的函式(例如 change_owner
)的權限檢查要改成像是 assert_only_self
這樣的檢查
userOp
相關資訊在原生 AA 中的「執行」階段,Account 合約可以知道交易的內容以及交易的 Hash 值,例如 zkSync 的 executeTransaction
函式參數本身就有 _txHash
以及完整的交易內容 _transaction
(link),StarkNet 則是可以透過系統函式 get_tx_info
去查詢交易內容(link)。但在 4337 中 Account 合約則沒辦法獲得任何 userOp
相關資訊,這可能會影響某些開發者在 Account 合約功能上的設計。
註 1:Account 合約可以在「驗證」階段將 userOp
資訊儲存到 Storage,但記得前面提到過 4337 會先「驗證」過所有 userOp
才「執行」,所以如果 Account 合約剛好有兩筆 userOp
一起打包執行,那第二筆的 userOp
在驗證時就會覆寫掉第一筆 userOp
儲存的資訊。
註 2:4337 Entrypoint 開發者有注意到這個需求,並在尋找可能的解法。
zkSync 在 Storage 讀取限制上多放寬一條規則(link):Account 合約可以讀取其他合約上 Storage 位址和自己地址一樣的 Storage,例如假設 Bob 的 Account 合約部署在地址 0xabc
上,則在驗證階段他的 Account 合約可以讀取其他合約上 Storage 位址在 0xabc
的資料。未來 zkSync 也可能開放讀取 timestamp 讓交易能具備時效性(link)。
StarkNet 則是不允許 Account 合約在「驗證」階段執行任何對外呼叫(link),也就表示 Account 合約只能讀取自身的 Storage。
4337 的 Entrypoint nonce
設計會區分 192 bit 的 key
值及 64 bit 的 nonce
值
zkSync 則沒有區分,只能單純遞增,但未來可能會支援更彈性的設計(link)
StarkNet 也沒有區分,甚至沒有抽象掉 nonce
,它和目前 Ethereum 協議一樣為每個地址管理一個固定遞增的 nonce
值(zkSync 是由一個 NonceHolder 合約管理 nonce
值)
開發者可以將 Account 合約的驗證邏輯實作在第三方合約(以下簡稱 Module)中,Account 合約在執行 validateUserOp
時就呼叫 Module 來進行驗證,Module 驗證完後再回傳結果。引入 Module 需要注意的地方是:Module 能在驗證過程需要某些資料(例如需要 Owner
地址來驗證簽章是否合法),這些資料要存在哪裡?
如果這些資料存在 Module 合約上,那 Module 就會受 4337 Storage 存取規則限制,例如只能用 mapping
存且不能用 nested mapping
等等
如果資料是儲存在 Account 合約身上,那 Account 合約可能得使用 delegatecall
進行呼叫,但這時就要小心惡意的 Module 是可以修改 Account 合約其他 Storage 資料的,使用者在選擇 Module 時必須要特別注意
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 這種代幣會要求如果接收者是合約的話,它要實作特定的函式(例如 onERC721Received
、onERC1155Received
),否則代幣轉帳就會失敗,而因為開發者預期未來還會有新的代幣標準出現,所以會讓 fallback
裡的邏輯可以更新,以便未來能支援新的代幣接收函式(例如 onERC5566Received
)。
另一個使用 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 預期呼叫合約的進入點都是透過 fallback
,fallback
裡面再按照 msg.data
去判斷該用哪個 Module 的邏輯來執行,但原生 AA 的 Account 合約有固定的進入點,例如 validateTransaction
、executeTransaction
、__validate__
、__execute__
等等,所以在原生 AA 的交易執行流程中是不會觸發 Account 合約的 fallback
的(除非是代幣轉帳觸發 onERC721Received
等等),因此在 4337 按照 ERC-6900 實作的 Account 合約不能直接原封不動地搬到原生 AA 中使用。
註:這裡搜集了許多模組化 Account 設計的資源。
採用模組化設計,在呼叫 Module 合約時盡量避免使用 delegatecall,因為你必須付出額外許多時間去檢查 Module 是否有可能無意間或惡意地去修改重要的 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
值中(link1、link2)。
註:Safe 則是不在合約本身做任何防護,而是透過 ERC-3770 來幫助使用者識別自己的錢包,並要求 Safe 使用者不要假設「錢包在不同鏈上的地址會是一樣的」(link)。
前面提到為了確保多鏈部署的安全性,會需要將控制權資訊例如 Owner 地址嵌入到部署資訊中,但這就會遇到一開始提到的問題了:開發者得在「是否用中心化部署」及「是否假設使用者會遺失初始私鑰」之間做選擇。
去中心化部署但使用者就不能遺失初始的私鑰:在這個設計當中,任何人都可以(替別人)部署錢包合約。部署地址由使用者的初始私鑰對應的地址所決定(例如將 Owner 地址嵌入 salt
中),且錢包合約在部署時會直接使用嵌入的地址當作 Owner。如此即便攻擊者搶先在其他鏈部署錢包合約到同一個地址,該錢包合約的擁有人一樣是使用者的初始地址。但如果使用者遺失初始私鑰,則等同於失去「他在所有其他鏈上還未部署的錢包合約的控制權」。
中心化部署但使用者可以放心更換私鑰:由一個中心化角色例如錢包商來負責錢包合約的部署,所以不需要擔心被搶先部署。部署地址不受使用者的初始私鑰對應的地址所影響,錢包合約在不同鏈上都可以部署到同一個地址且又能在每次部署時讓使用者自己指定 Owner 地址,如此使用者就不需要一直保管著初始私鑰。但使用者需要相信錢包商,如果錢包商作惡,它可以自行部署並拿走使用者在其他鏈上還未部署的錢包合約的控制權。
開發者要在「是否用中心化部署」及「是否假設使用者會遺失初始私鑰」之間做選擇並不是個好的解法,理想的解法是如同 Safe 所說,使用者不該假設他的錢包合約在其他鏈上一樣會部署到同一個地址,但要能做到這件事,錢包們得提供一個夠好的使用體驗,讓使用者不需用難記的「地址」來識別錢包,而是用方便好記的「ENS 名稱」來識別錢包。如此即便 Alice 的錢包合約在不同鏈上都部署在不一樣的地址,但這些錢包合約都是「alice.eth
」底下的子域名,例如「arbitrum.alice.eth
」、「optimism.alice.eth
」等等。
因為 4337 不是原生 AA,所以 Debug userOp 方式不像 Debug Ethereum 交易一樣,而更像是在 Debug 智能合約的執行。
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 的編碼。例如假設你的 userOp
裡 nonce
給錯導致驗證 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
則是錯誤訊息 reason
。AA25
這個錯誤在這裡觸發。
當你瀏覽過 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
執行失敗時的 Debug 方式,但如果 userOp
不幸送到鏈上卻執行失敗呢?Etherscan 目前已經有支援顯示 Custom Error,所以你可以透過 Etherscan 的交易資訊主頁面看到錯誤訊息:
但如果 userOp 是在「執行」階段才執行失敗的話,這時 Bundler 的交易是不會失敗的,Account 合約還是要照付手續費給 Bundler。因此這種情況你不會在交易資訊主頁面看到執行失敗的錯誤訊息,而是要去查詢交易的 Log,因為 Entrypoint 在這種情況會成功執行並透過 UserOperationEvent
這個 Event 去公布這筆 userOp 執行的結果(link):
另外像是 4337 專門的瀏覽器,例如 Blocknative 的 4337 瀏覽器,裡面顯示的錯誤目前還是非常不清楚:
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 上的難度
錄影:Workshop: AA wallet (EIP-4337) workshop | imToken | ETHTaipei 2023 。Account 合約部分從 37:00 左右開始。
範例程式碼:GitHub - consenlabs/ethtaipei2023-aa-workshop: Account abstraction workshop @ ETHTaipei 2023
錄影