本文介绍了以太坊中的账户抽象(Account Abstraction, AA),特别是基于 EIP-2938 的 AA 概念。
本文主要由 EIP-2938 介紹 Account Abstraction (AA) 的相關內容。
在 Ethereum 中有兩種 accounts,分別為 EOA 和 Contract Account,而 EOA 的 所有權 和 簽核權 理論上是同一個個體單位持有,簡單來說便是:持有 私鑰 的人不只擁有這個 Account 的「所有權」,同時還可以「任意轉移所有資產」。這個定義是被刻在以太坊上的,然而現今的 EOA 設計可能會衍伸出一些值得討論的問題。
目前有許多新興的帳戶概念,例如使用 Relay Server 的 Meta Transaction、多簽錢包,以及將要介紹的 Account Abstraction 抽象帳戶。
Account Abstraction 能夠將 所有權(Owner) 和 簽核權(Signer) 解耦(decouple)。
Account Abstraction 很像是擁有 EOA 特性的 Contract Account,它到底哪裡比較優勢呢?實際上讓交易和帳戶從底層脫離成為 High-Level 智能合約的角色,能夠做到:
這篇文章會希望讀者已經對 EOA、Contract Account 有一定的了解,以及熟悉 Public/Private Key、Signature、nonce,尤其是 Transaction & State(e.g. Balance)、EVM 等相關概念。
Account Abstraction 希望將帳戶的概念移植到智能合約之中,就和我們時常看見的 Multi-Sig. Contract 很像,只是 Multi-Sig. Contract 仍然是由一個 EOA 作為 Gas Account 的角色來發起交易和支付 Gas,交易驗證方式也和往常相差無幾,而 AA 希望可以透過智能合約來作為唯一支付者。
AA Contract 要做的事情是有個 相同的介面標準(Interface) 供大家驗證或執行交易。
相關概念歷經了以下的提案慢慢完整中:
EIP-2938: Account Abstraction 首先提出了一個完整的 AA 概念,可以說是 AA 的一種 specification。提案中表示:像 AA 這樣一個特殊的 Contract Account 可以接收和發送「指定型態的交易」、以各種方式支付交易手續費,以及能夠使用程式碼去定義交易的 maximum gas 和 verification method。
在這樣一個「新的帳戶 & 交易」設計裡與「現今的 EOA & 交易」有不少的差別:
AA_TX_TYPE
。PAYGAS (0x49)
和 NONCE (0x48)
。前者待下文詳述;後者能夠 push 交易物件中的 nonce field 到 EVM stack 中(未來能與 contract 中儲存的 nocne
變數進行驗證 : tx.nonce == contract.nonce
,文末有相關程式碼範例)。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
。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
這個環境變數的地址。
我們可以把 Abstract Acoount 分成兩種類別,差別在於這個 AA 用來支援少數用戶或大量用戶:
Multi-Tenant AA 會被 Tornado.Cash 或 Uniswap 這樣的應用程式使用,設計中會有一個主合約來代表這個應用程式,當然根據需求實作的時候要設計一個專屬於此 Dapp 的交易驗證方式。在文末我會提到 Tornado.Cash 使用 AA 的範例介紹。
在 EIP-2938 裡的節點 mempool 中,每一個 AA 帳戶僅能有一個 pending transaction。
提出者也計畫未來滿足 Multi-Tenant AA 的功能時,會對單一個 account 支援多個 pending transactions,這部分的主要挑戰在於「單一筆交易可能使其他所有交易失效」,以及「單純以 gas price 作為交易的優先次序,可能會導致惡意交易排擠其他交易」。
在 AA 的交易型態下,交易驗證變得比現在彈性許多,從節點的 tx mempool 角度來看,要驗證交易就跟在合約中驗證一樣,需要 EVM 執行之後才能知道結果,這使得惡意交易可以排擠在 tx mempool 中的其他交易。
EIP-2938 針對多個 pending tx 提出來的一種減輕方案(尚未完成)為:
然而在 multi-tenant AA 的應用應該很難避免去讓交易彼此不重疊,因此 access list 可能比較適合 single-tenant AA 使用。
在 multi-tenant AA 的情況中 miner 仍然可以去修改每一筆接收到的 transaction 的 nonce,因此最後交易的次序是無法被預測的,用戶需要特別去注意這件事情。
過往節點對於交易的驗證只需要確認「簽章合法、nonce 合法、gas 與 balance 足夠」,在 AA 中這些交易驗證都需要移動到合約裡(以 EVM 運算驗證),成為 top-level code execution。而節點還是需要有能力去判斷這些交易是否合法與真的付了 fee, 也就是待會提到的 Node 驗證內容。
驗證階段主要分為兩個部分: PAYGAS
以前的驗證階段(verification phase),與 PAYGAS
以後的執行階段(execution phase)。
Source: Implementing account abstraction as part of eth1.x
當交易發起時,第一步會從 EVM Stack 獲得 gas_price
與 gas_limit
,接下來進行一連串的檢查:
>= gas_price * gas_limit
globals.transaction_fee_paid == False
:此條件等同於 type(tx) == AA_TX_TYPE
如果以上三點條件都滿足,則執行下列步驟:
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)。
傳統的交易中,節點會去檢驗 nonce 的合法性,還原出簽章地址之後查看是否有足夠的 balance 以及 gas fees。
在 AA 的新形態交易中:節點一樣會去驗證 nonce 的合法性( tx.nonce == tx.target.nonce
),也就是不能有高過於當前已驗證 nonce 的 transaction。但因為面對的是新型態的交易,節點不會驗證既往的簽章而是驗證其他內容,步驟如下:
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 則終止並回傳錯誤:
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,如果是的話則接受該交易驗證,否則拒絕此筆交易
以上的限制可以確保所有的狀態存取只能在合約之中,也只有合約本身可以去改動這些狀態。
Source: A recap of where we are at on account abstraction
回到我們之前提過的:One Account One Pending Transaction。
如果現在 mempool 中 AA account 已經存在一個 valid nonce 的 transaction,當一個相同 nonce 的新 transaction 進入此 contract 時,只會有兩種情況:
因此 mempool 中每個 account 最多只會保留一個 pending transaction。
我們可以看一下 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。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
的簽核 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);
}
Source: Ethereum Account Abstraction by Vitalik Buterin
另外一個能夠套用 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 就能用將要獲得的這筆資金來支付提款交易的手續費。
關於 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!
- 本文转载自: medium.com/taipei-ethere...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!