Account Abstraction 抽象帳戶:EIP-2938 簡介
本文主要由 EIP-2938 介紹 Account Abstraction (AA) 的相關內容。
Table of Contents
- Introduction
- Cast an eye over Account Abstraction
- Transaction Validation
- Examples Quick Look
- Closing
Introduction
在 Ethereum 中有兩種 accounts,分別為 EOA 和 Contract Account,而 EOA 的所有權和簽核權理論上是同一個個體單位持有,簡單來說便是:持有私鑰的人不只擁有這個 Account 的「所有權」,同時還可以「任意轉移所有資產」。這個定義是被刻在以太坊上的,然而現今的 EOA 設計可能會衍伸出一些值得討論的問題。
- 失去私鑰(遺失、遭駭、密碼學上的被破解)意味著毫無疑問地失去所有資產
- 原生協議在驗證交易上只能使用 ECDSA 這個簽章演算法
- 一筆交易無法透過多簽授權(只能透過智能合約實作)
- 手續費只能透過 ether 支付
- 一對一的交易容易對應出帳戶持有者的隱私資訊,或在隱私應用(Privacy Solution)上需要依賴 relayer。
目前有許多新興的帳戶概念,例如使用 Relay Server 的 Meta Transaction、多簽錢包,以及將要介紹的 Account Abstraction 抽象帳戶。
Account Abstraction 能夠將所有權(Owner)和簽核權(Signer)解耦(decouple)。
Account Abstraction 很像是擁有 EOA 特性的 Contract Account,它到底哪裡比較優勢呢?實際上讓交易和帳戶從底層脫離成為 High-Level 智能合約的角色,能夠做到:
- 多簽錢包或 Social recovery
- 使用其他簽章演算法:過往只能使用以 Address 作為 Verification Data 的 ECDSA ,但在 AA 中可以使用其他演算法,例如: Multisig verification、Schnorr sigs、BLS sigs、Quantum-resistant sigs (e.g. Lamport, Winternitz)。
- 將一連串的 operations 包裝成一個不可分割的(atomic)交易行為
- 可升級
Background
這篇文章會希望讀者已經對 EOA、Contract Account 有一定的了解,以及熟悉 Public/Private Key、Signature、nonce,尤其是 Transaction & State(e.g. Balance)、EVM 等相關概念。
Cast an eye over Account Abstraction
Account Abstraction 希望將帳戶的概念移植到智能合約之中,就和我們時常看見的 Multi-Sig. Contract 很像,只是 Multi-Sig. Contract 仍然是由一個 EOA 作為 Gas Account 的角色來發起交易和支付 Gas,交易驗證方式也和往常相差無幾,而 AA 希望可以透過智能合約來作為唯一支付者。
AA Contract 要做的事情是有個 相同的介面標準(Interface) 供大家驗證或執行交易。
相關概念歷經了以下的提案慢慢完整中:
- EIP-86: Abstraction of transaction origin and signature
- EIP-101: Currency and Crypto Abstraction
- EIP-859: Account abstraction for main chain
- EIP-2718: Typed Transaction Envelope
- EIP-2938: Account Abstraction
- EIP-4337: Account Abstraction via Entry Point Contract specification
EIP-2938
EIP-2938: Account Abstraction 首先提出了一個完整的 AA 概念,可以說是 AA 的一種 specification。提案中表示:像 AA 這樣一個特殊的 Contract Account 可以接收和發送「指定型態的交易」、以各種方式支付交易手續費,以及能夠使用程式碼去定義交易的 maximum gas 和 verification method。
New Type Transaction
在這樣一個「新的帳戶 & 交易」設計裡與「現今的 EOA & 交易」有不少的差別:
- 新的交易型態名稱為:
AA_TX_TYPE
。 - 需要新的 OpCodes
PAYGAS (0x49)
和NONCE (0x48)
。前者待下文詳述;後者能夠 push 交易物件中的 nonce field 到 EVM stack 中(未來能與 contract 中儲存的nocne
變數進行驗證 :tx.nonce == contract.nonce
,文末有相關程式碼範例)。 - 需要新的 Global Variable:
globals.transaction_fee_paid
、globals.gas_price
、globals.gas_limit
。 - 交易物件也和我們原本認識的
from
、to
、gas
、gaslimit
、value
、v, r, s
等 fields 不同,AA 的交易物件主要由三個 fields 構成:target
(為 AA contract’s address)、nonce
、data
。 - 交易的 payload 為
rlp([nonce, target, data])
- 這個特殊的交易形式會把
tx.origin
設為常數AA_ENTRY_POINT = 0xffff...ff
,此地址是既定的 AA entry point。
由於所有的帳戶都將變成「合約」,每一筆交易都會變成一個「Call」,「這個 Call」的起源(tx.origin
)都需要是定義好的 entry point address(eg. 0xffff…ff
)。換句話說 AA contracts 需要檢查每次交易的 tx.origin
是否是 AA_ENTRY_POINT
這個環境變數的地址。
Classification
我們可以把 Abstract Acoount 分成兩種類別,差別在於這個 AA 用來支援少數用戶或大量用戶:
- Single-Tenant AA
- Multi-Tenant AA
Multi-Tenant AA 會被 Tornado.Cash 或 Uniswap 這樣的應用程式使用,設計中會有一個主合約來代表這個應用程式,當然根據需求實作的時候要設計一個專屬於此 Dapp 的交易驗證方式。在文末我會提到 Tornado.Cash 使用 AA 的範例介紹。
One Account One Pending Transaction
在 EIP-2938 裡的節點 mempool 中,每一個 AA 帳戶僅能有一個 pending transaction。
提出者也計畫未來滿足 Multi-Tenant AA 的功能時,會對單一個 account 支援多個 pending transactions,這部分的主要挑戰在於「單一筆交易可能使其他所有交易失效」,以及「單純以 gas price 作為交易的優先次序,可能會導致惡意交易排擠其他交易」。
在 AA 的交易型態下,交易驗證變得比現在彈性許多,從節點的 tx mempool 角度來看,要驗證交易就跟在合約中驗證一樣,需要 EVM 執行之後才能知道結果,這使得惡意交易可以排擠在 tx mempool 中的其他交易。
EIP-2938 針對多個 pending tx 提出來的一種減輕方案(尚未完成)為:
- 使用 EIP-2930-style 的 access list。這一個 List 會詳述每一筆交易需要使用(read / write)到的 storage slot,因此如果踩到 list 以外範圍的交易就會被拒絕。衍伸閱讀:Account read/write lists
- 在 mempool 中,每筆交易的 Access List 都必須與其他交易的 list 互相獨立(因為交易彼此之間有相依性容易排擠其他交易),除非兩筆交易中有 gasprice 的差距(能夠排出優先次序)。
然而在 multi-tenant AA 的應用應該很難避免去讓交易彼此不重疊,因此 access list 可能比較適合 single-tenant AA 使用。
在 multi-tenant AA 的情況中 miner 仍然可以去修改每一筆接收到的 transaction 的 nonce,因此最後交易的次序是無法被預測的,用戶需要特別去注意這件事情。
Transaction Validation
過往節點對於交易的驗證只需要確認「簽章合法、nonce 合法、gas 與 balance 足夠」,在 AA 中這些交易驗證都需要移動到合約裡(以 EVM 運算驗證),成為 top-level code execution。而節點還是需要有能力去判斷這些交易是否合法與真的付了 fee,也就是待會提到的 Node 驗證內容。
驗證階段主要分為兩個部分:PAYGAS
以前的驗證階段(verification phase),與 PAYGAS
以後的執行階段(execution phase)。
- 前者限制交易只能閱讀合約的 storage(e.g. public keys, anti-replay nonces, Merkle roots) 以及不能更改任何狀態,這個階段失敗了礦工就不會收入這筆交易
- 後者因為已經支付了 fee,如果交易在這個階段失敗,礦工仍然可以選擇要不要加入這筆交易。
PAYGAS & Verification Phase
當交易發起時,第一步會從 EVM Stack 獲得 gas_price
與 gas_limit
,接下來進行一連串的檢查:
- Contract’s balance
>= gas_price * gas_limit
globals.transaction_fee_paid == False
:此條件等同於type(tx) == AA_TX_TYPE
- 仍然在執行合約(EVM 運作)階段。
如果以上三點條件都滿足,則執行下列步驟:
- 從 Contract’s balance 扣掉
gas_price * gas_limit
- 將
globals.transaction_fee_paid
設為true
- 將
globals.gas_price
設為gas_price
,將globals.gas_limit
設為gas_limit
- 設定
remaining gas = gas_limit - gas_consumed
以上交易步驟結束之後:
- 會將
globals.gas_price * remaining_gas
退款回 contract 中 - 並把
(globals.gas_limit - remaining_gas) * globals.gas_price
作為獎勵給予礦工
透過以上的檢查點與步驟,PAYGAS (0x49)
能夠建立一個不可逆的(irreversible)檢查點來確保 PAYGAS
之前的狀態改變不能夠被 reverse,且交易只查看了合約內部的狀態(不包含 pre-compile)。
Nodes Policy
傳統的交易中,節點會去檢驗 nonce 的合法性,還原出簽章地址之後查看是否有足夠的 balance 以及 gas fees。
在 AA 的新形態交易中:節點一樣會去驗證 nonce 的合法性(tx.nonce == tx.target.nonce
),也就是不能有高過於當前已驗證 nonce 的 transaction。但因為面對的是新型態的交易,節點不會驗證既往的簽章而是驗證其他內容,步驟如下:
- 檢查 entry point:如果
target
的程式碼(bytecode)沒有AA_PREFIX: if(msg.sender != shr(-1, 12)) { LOG1(msg.sender, msg.value); return }
作為 prefix 則終止並回傳錯誤;這個檢查等價於在合約最前方加上require(msg.sender == ENTRY_POINT)
Nit: ENTRY_POINT = shr(-1, 12) = 2**160 - 1 = 1.4615016e+48
2. 在 PAYGAS
之前使用到以下 OpCodes 則終止並回傳錯誤:
- Environment Opcodes:
BLOCKHASH
COINBASE
,TIMESTAMP
,NUMBER
,DIFFICULTY
,GASLIMIT
- 任何帳戶的
BALANCE
(包含target
) - 任何會將
callee
轉為除了target
以外角色的 external call/create:CALL
,CALLCODE
,STATICCALL
,CREATE
,CREATE2
- 任何存取外部狀態的行為:
EXTCODESIZE
,EXTCODEHASH
,EXTCODECOPY
,CALLCODE
和DELEGATECALL
3. 執行期間消耗的 gas 多於 VERIFICATION_GAS_CAP = VERIFICATION_GAS_MULTIPLIER * AA_BASE_GAS_COST = 90000
則終止並回傳錯誤;這個上限是為了防止節點花費一堆 gas 結果最後驗證失敗,釀成 DoS 風險。
DoS 延伸閱讀:DoS Vectors in Account Abstraction (AA) or Validation Generalization, a Case Study in Geth
4. 遇見 PAYGAS
後會檢查是否有足夠的 balance,如果是的話則接受該交易驗證,否則拒絕此筆交易
以上的限制可以確保所有的狀態存取只能在合約之中,也只有合約本身可以去改動這些狀態。
回到我們之前提過的:One Account One Pending Transaction。
如果現在 mempool 中 AA account 已經存在一個 valid nonce 的 transaction,當一個相同 nonce 的新 transaction 進入此 contract 時,只會有兩種情況:
- 去取代當前的 valid transaction(如果新加入者 gas price 比較高)
- 新加入的 transaction 被拋棄
因此 mempool 中每個 account 最多只會保留一個 pending transaction。
Examples Quick Look
Example 1 — AA Playground
我們可以看一下 Account Abstraction Playground 中的 wallet.sol
,這是一個模擬 EIP-2938 的 AA Wallet 例子,需要注意這個模擬範例並不是定案,也跟 EIP-4337 和 EIP-3074 的實作方式不同!
首先引入套件跟宣告版本:
// SPDX-License-Identifier: MITpragma solidity 0.6.10;
pragma experimental AccountAbstraction; import { ECDSA } from "./ECDSA.sol";
接下來開始宣告合約物件:
- 能看見跟往常不一樣,在最前方出現了一個新的關鍵字
account
能夠讓合約接收 AA Transaction,以及emit
特定事件。 - 宣告
nonce
讓 AA contract 可以達到 replay protection。 - 從這個 AA Contract 要進行交易時需要
owner
的 signature,如果我們今天要使用多簽功能,則這個部分的owner
可以有複數個(當然後面的transfer()
等相關函式都要針對這種情況處理)。
account contract Wallet is ECDSA {
uint256 nonce;
address owner;
constructor() public payable {
owner = msg.sender;
}
使用上 assembly { paygas(gasPrice)}
能利用 Opcode PAYGAS
達到「告知交易是合法」以及「檢查點」的作用,在 transfer()
中我們也能看到同樣的作用。
AA 合約在執行時,無論是對狀態 write 或 read 都需要 PAYGAS
作為 signal,因此下方程式碼的 Get Function 會加上這個部分的 modifier。傳 0 是因為發起 read function 時不需要支付 gas,但跟我們一開始說的一樣,仍須要有一個 signal 存在。
modifier paygasZero {
assembly { paygas(0) }
_;
} function getNonce() public view paygasZero returns (uint256) {
return nonce;
}
function getOwner() public view paygasZero returns (address) {
return owner;
}
}
transfer()
會在 high-level 的 AA Contract 中模擬 low-level 運作的交易。
以下的重點在於:
- 這裡傳入了
signature
表示此筆交易需要owner
的簽核 - AA Contract 可以不必使用 ECDSA 作為簽章演算法
- 交易物件是彈性的,每個 AA Contract 可以自己決定交易物件的內容與需求
function transfer(
uint256 txNonce,
uint256 gasPrice,
address payable to,
uint256 amount,
bytes calldata signature
) public {
assert(nonce == txNonce);
bytes32 hash = keccak256(abi.encodePacked(this, txNonce, gasPrice, to, amount));
bytes32 messageHash = toEthSignedMessageHash(hash);
address signer = recover(messageHash, signature);
require(signer == owner);
nonce = txNonce + 1;
assembly { paygas(gasPrice) }
to.transfer(amount);
}
Example 2 — Privacy Solution: Tornado.Cash
另外一個能夠套用 AA 的例子是 Tornado.Cash,現今的 Tornado.Cash 運作方式為我們將資金存入 TC Contract 中並得到一串密鑰,且這筆存款紀錄會存至合約中的 Merkle Root。
當 User 想要提取資金時,需要根據之前的密鑰產生他們真的在 deposit-tree 中的 ZK-SNARK,而 TC Contract 會在成功驗證這個 ZKP 以及資金未被花用後匯款給 User。
然而在 TC 中有一個問題,那就是當收款者(withdrawal address)還沒有任何的 ETH,那他就沒辦法啟動這筆提款的 proving transaction。如果他用存款的帳戶(deposit address)來支付這筆 gas,那就能在鏈上串連起這兩個地址,隱蔽性也就蕩然無存了。
現行的解決方案是需要有一個第三方的 Relayer 來接收 Off-Chain Message ,驗證 ZK-SNARK 以及確認這筆資金未被使用。驗證完畢後提交交易物件給 TC Contract(並獲得 Gas refund 和 fee),最後 TC Contract 會匯出款項。
如果套用 AA 的使用情境就不需要 Relayer 了,交易物件(AA Transaction)可以直接被送到 TC Contract,而 TC Contract 能夠在 verification step(也就是之前提到 PAYGAS
前的第一階段)驗證 Snarks 與確認資金未被花用後,直接送出匯款同時支付 Gas Fee。
這樣 withdrawal address 就能用將要獲得的這筆資金來支付提款交易的手續費。
Closing
關於 AA 比較廣為人知的討論是在 EIP-2938 之後的 EIP-4337 與 EIP-3074,先了解 EIP-2938 可以很大幅度的幫助了解之後的各種延伸 Proposal。以上這些提案大部分都還在研擬、review 甚至是 draft 階段,但 AA 其實在 L2 上有很大的發展性值得我們深入研究。
本文省略了很多討論問題,例如 Replay Protection & Transaction Hash Uniqueness,Protocol-enshrined Nonce, Create2 & Proxy, Bottleneck & Security 等,對這些討論有興趣可以見下方的 Reference 處和提案內文。
這篇文章非常感謝 Chang-Wu Chen 與 NIC Lin 兩位老師提供 review!
Reference
- Account Abstraction Link Tree
- Implementing account abstraction as part of eth1.x
- Account Abstraction, Stateless Mining Eth1.x/Eth 2 Implementation, Rationale Document
- AA Open Questions
- A recap of where we are at on account abstraction
- Tradeoffs in Account Abstraction Proposals
- Maximally simple account abstraction without gas refunds