簡介 zkSync Era 原生支援帳戶抽象 (Account Abstraction) 的功能,這篇文章會簡單介紹一下帳戶抽象是什麼,主要會說明在 zkSync Era 如何實作相關的程式。
帳戶類型 在介紹帳戶抽象之前,要先知道目前以太坊有兩種帳戶
外部擁有帳戶,英文為 EOA(Externally Owned Account),也就是一般有私鑰的錢包地址。 智能合約帳戶。 智能合約常會使用 msg.sender
來取得函式呼叫者,進一步作為在系統中的帳號,作為記帳之類的應用。EOA 的一般錢包可以直接發起交易來使用智能合約,而有時我們會使用另外的智能合約間接使用 dapp,例如多簽合約來管理,而這些智能合約帳戶本身無法自己發起交易,必須還是要有一般錢包來代為發送上鏈。
帳戶抽象 (Account Abstraction) 現行的帳戶機制存在一些問題,例如 EOA 帳戶私鑰必須保管好不能遺失,智能合約帳戶本身無法自行發起交易等等。帳戶抽象被提出就是為了解決相關的問題的機制,可以參考完整的文件 EIP-4337 和 EIP-2938 。網路上已經有很多關於帳戶抽象的介紹,這邊只簡單說明一下。
而這個抽象帳戶實際上仍然是以智能合約的方式來實作,能夠自行定義帳戶的管理邏輯,例如實作多簽的功能,同時還要實作一些指定的介面讓他能夠運作。而技術上最主要的問題就是讓智能合約帳戶能夠主動發起交易,這就需要鏈層級的支援,zkSync Era 原生支援了這個功能,可以理解為增加抽象帳戶為第三種帳戶類型,也就是本文主要要介紹的部分。
在 zkSync Era 中,要實作一個完整的抽象帳戶,必須實作的介面如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 interface IAccount { function validateTransaction ( bytes32 _txHash, bytes32 _suggestedSignedHash, Transaction calldata _transaction ) external payable returns (bytes4 magic ) ; function executeTransaction ( bytes32 _txHash, bytes32 _suggestedSignedHash, Transaction calldata _transaction ) external payable ; function executeTransactionFromOutside (Transaction calldata _transaction ) external payable ; function payForTransaction ( bytes32 _txHash, bytes32 _suggestedSignedHash, Transaction calldata _transaction ) external payable ; function prepareForPaymaster ( bytes32 _txHash, bytes32 _possibleSignedHash, Transaction calldata _transaction ) external payable ;}
可以參考官方 IAccount 原始碼 ,但 zkSync Era 定義的介面和 EIP-4337 文件並不一致。
Bootloader Bootloader 是系統中一個特殊的系統合約 (System Contract),zkSync Era 中會透過 bootloader 來執行帳戶抽象相關交易。上面介面中的 validateTransaction()
和 executeTransaction()
就是設計專門給 bootloader 呼叫的,executeTransactionFromOutside()
則是考慮給外部其他角色使用。
Paymaster Paymaster 是 EIP-4337 的擴充功能,可以使用不同的方式來支付 Gas 手續費,本篇文章暫時不討論。
運作流程 流程有分為使用 Paymaster 和沒有使用的兩種,這邊先暫時不討論 Paymaster,所以這邊先簡化官方文件 的流程為下:
驗證 nonce 是否正確。 呼叫 validateTransaction()
驗證交易是否可執行,同時確認 nonce 標記為使用過。 呼叫 payForTransaction()
來支付手續費。 呼叫 executeTransaction()
執行交易。 前置準備 以下的範例原始碼 放在 Github 上,可以直接參考。首先我們先實做一個簡單的智能合約,用來之後給抽象帳戶呼叫之用,
1 2 3 4 5 6 7 8 9 10 pragma solidity ^0.8.0;contract Hello { address public caller; function hi ( ) external { caller = msg .sender ; } }
接著安裝 zkSync 官方的套件 @matterlabs/zksync-contracts
,來進行後續開發:
1 npm install --save-dev @matterlabs/zksync-contracts
繼承 IAccount
介面開始實作:
1 2 3 4 5 6 7 8 pragma solidity ^0.8.0;import "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IAccount.sol" ;contract MyAccount is IAccount { }
設定 hardhat.config.ts 要使用系統合約還需要額外設定,在 hardhat.config.ts
的 zksolc 區塊加入 isSystem: true
,例如:
1 2 3 4 5 6 7 8 9 { zksolc : { version : "latest" , settings : { isSystem : true } } }
沒設定的話在後面 deployAccount 的時候會出現錯誤
1 Error: cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPREDICTABLE_GAS_LIMIT ] (error={"reason":"execution reverted: Deployment failed","code":"UNPREDICTABLE_GAS_LIMIT","method":"estimateGas","transaction":{...}, code=UNPREDICTABLE_GAS_LIMIT, version=abstract-signer/5.7.0)
Account 除了基本功能的實作之外,也加上一些檢驗,這裡使用 owner 簽章的方式來檢驗交易執行的權限。驗證簽章相關資料可以參考 Solidity 智能合約 - ecrecover 簽章檢驗 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 pragma solidity ^0.8.0;import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol" ;import "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IAccount.sol" ;import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol" ;import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol" ;import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/EfficientCall.sol" ;import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol" ;contract MyAccount is IAccount { using TransactionHelper for Transaction ; address public owner; modifier onlyBootloader ( ) { require (msg .sender = = BOOTLOADER_FORMAL_ADDRESS, "Only bootloader can call this method" ); _ ; } constructor (address _owner ) { owner = _owner; } function validateTransaction ( bytes32 , bytes32 _suggestedSignedHash, Transaction calldata _transaction ) external payable override onlyBootloader returns (bytes4 magic ) { magic = _validateTransaction(_suggestedSignedHash, _transaction); } function _validateTransaction ( bytes32 _suggestedSignedHash, Transaction calldata _transaction ) internal returns (bytes4 magic ) { SystemContractsCaller.systemCallWithPropagatedRevert( Utils.safeCastToU32(gasleft ()), address (NONCE_HOLDER_SYSTEM_CONTRACT), 0 , abi .encodeCall (INonceHolder.incrementMinNonceIfEquals, (_transaction.nonce)) ); uint256 totalRequiredBalance = _transaction.totalRequiredBalance(); require (totalRequiredBalance < = address (this ).balance , "Not enough balance for fee + value" ); bytes32 txHash = _suggestedSignedHash ! = bytes32 (0 ) ? _suggestedSignedHash : _transaction.encodeHash(); if (_isValidSignature(txHash, _transaction.signature)) { magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC; } } function executeTransaction ( bytes32 , bytes32 , Transaction calldata _transaction ) external payable override onlyBootloader { _executeTransaction(_transaction); } function _executeTransaction (Transaction calldata _transaction ) internal { address to = address (uint160 (_transaction.to)); uint128 value = Utils.safeCastToU128(_transaction.value ); bytes calldata data = _transaction.data; uint32 gas = Utils.safeCastToU32(gasleft ()); bool isSystemCall; if (to = = address (DEPLOYER_SYSTEM_CONTRACT) & & data.length > = 4 ) { bytes4 selector = bytes4 (data[:4 ]); isSystemCall = selector = = DEPLOYER_SYSTEM_CONTRACT.create.selector | | selector = = DEPLOYER_SYSTEM_CONTRACT.create2.selector | | selector = = DEPLOYER_SYSTEM_CONTRACT.createAccount.selector | | selector = = DEPLOYER_SYSTEM_CONTRACT.create2Account.selector ; } bool success = EfficientCall.rawCall(gas, to, value, data, isSystemCall); if (! success) { EfficientCall.propagateRevert(); } } function executeTransactionFromOutside ( Transaction calldata _transaction ) external payable { _validateTransaction(bytes32 (0 ), _transaction); _executeTransaction(_transaction); } function _isValidSignature ( bytes32 _hash, bytes memory _signature ) internal view virtual returns (bool ) { return ECDSA.recover(_hash, _signature) = = owner; } function payForTransaction ( bytes32 , bytes32 , Transaction calldata _transaction ) external payable override onlyBootloader { bool success = _transaction.payToTheBootloader(); require (success, "Failed to pay the fee to the operator" ); } function prepareForPaymaster ( bytes32 , bytes32 , Transaction calldata _transaction ) external payable override onlyBootloader { _transaction.processPaymasterInput(); } fallback ( ) external payable { assert (msg .sender ! = BOOTLOADER_FORMAL_ADDRESS); } receive ( ) external payable { } }
上面大部分程式其實可以視為固定寫法,主要是自行定義驗證方式 _isValidSignature()
,如果完全不驗證甚至可以不用 owner 直接回傳 true
。
然而如果直接將上面的合約用一般的方式部署,會發現無法使用,在發送 zkSync 抽象帳戶交易的時候,會出現以下錯誤:
1 Validation revert: Sender is not an account
這是因為要建立抽象帳戶必須使用特殊的方式來執行。
Factory 要建立抽象帳戶必須先部署一個 Factory 合約:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 pragma solidity ^0.8.0;import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol" ;import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol" ;contract MyFactory { bytes32 public aaBytecodeHash; constructor (bytes32 _aaBytecodeHash ) { aaBytecodeHash = _aaBytecodeHash; } function deployAccount ( bytes32 salt, address owner ) external returns (address accountAddress ) { (bool success, bytes memory returnData) = SystemContractsCaller .systemCallWithReturndata( uint32 (gasleft ()), address (DEPLOYER_SYSTEM_CONTRACT), uint128 (0 ), abi .encodeCall ( DEPLOYER_SYSTEM_CONTRACT.create2Account, (salt, aaBytecodeHash, abi .encode (owner), IContractDeployer.AccountAbstractionVersion.Version1) ) ); require (success, "Deployment failed" ); (accountAddress) = abi .decode (returnData, (address )); } }
其中 abi.encode(owner)
的部分為建構子,帶入一個參數。
部署 準備好上面的合約之後就可以部署測試,以下使用 zksync-cli 建立的專案來進行,預設就會裝好所需的套件,可以撰寫在部署或者是測試中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import { utils, Provider , Wallet , types, EIP712Signer } from 'zksync-web3' ;import * as ethers from 'ethers' ;import * as hre from 'hardhat' ;import { Deployer } from '@matterlabs/hardhat-zksync-deploy' ;const RPC_URL = '...' ;const PRIVATE_KEY = '...' ;const provider = new Provider (RPC_URL );const wallet = new Wallet (PRIVATE_KEY , provider);const deployer = new Deployer (hre, wallet);const helloArtifact = await deployer.loadArtifact ('Hello' );const factoryArtifact = await deployer.loadArtifact ('MyFactory' );const accountArtifact = await deployer.loadArtifact ('MyAccount' );const hello = await deployer.deploy (helloArtifact);const factory = await deployer.deploy ( factoryArtifact, [utils.hashBytecode (accountArtifact.bytecode )], undefined , [accountArtifact.bytecode ] ); const owner = Wallet .createRandom ();const salt = ethers.constants .HashZero ;(await factory.deployAccount (salt, owner.address )).wait (); const abiCoder = new ethers.utils .AbiCoder ();const accountAddress = utils.create2Address ( factory.address , await factory.aaBytecodeHash (), salt, abiCoder.encode (['address' ], [owner.address ]) ); await ( await wallet.sendTransaction ({ to : accountAddress, value : ethers.utils .parseEther ('0.01' ) }) ).wait ();
呼叫合約 呼叫合約的方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 let aaTx = await hello.populateTransaction .hi ();aaTx = { ...aaTx, from : accountAddress, gasLimit : await provider.estimateGas (aaTx), gasPrice : await provider.getGasPrice (), chainId : (await provider.getNetwork ()).chainId , nonce : await provider.getTransactionCount (accountAddress), type : 113 , customData : { gasPerPubdata : utils.DEFAULT_GAS_PER_PUBDATA_LIMIT , } as types.Eip712Meta , value : ethers.BigNumber .from (0 ) }; const signer = new EIP712Signer (owner, aaTx.chainId !);aaTx.customData = { ...aaTx.customData , customSignature : await signer.sign (aaTx) }; (await provider.sendTransaction (utils.serialize (aaTx))).wait (); const caller = await hello.caller ();
原本呼叫合約的方法是用 hello.connect(wallet).hi();
方式直接送出,這邊要改成使用 populateTransaction
產生 tx 內容,然後在 tx 內容多加上一些抽象帳戶特有的資訊,例如 type
和 customData
的資料。
如果要實作多簽,可以改為多個 owner 進行簽署,把多個簽章結合起來帶入 customSignature
,最後在合約中檢驗簽章的時候把 _transaction.signature
切開來檢驗。可以參考官方範例 。
實測結果 這是一個實際部署到測試鏈的抽象帳戶 ,在區塊瀏覽器上可以看到它看起來是一個 Contract,而下方的交易紀錄可以看到他能夠主動發起交易:
延伸閱讀 使用 Ethers.js 操作 zkSync 帳戶抽象 使用 Web3.js 操作 zkSync 帳戶抽象 Solidity - zkSync Paymaster 程式範例