簡介

zkSync Era 原生支援帳戶抽象 (Account Abstraction) 的功能,這篇文章會簡單介紹一下帳戶抽象是什麼,主要會說明在 zkSync Era 如何實作相關的程式。

帳戶類型

在介紹帳戶抽象之前,要先知道目前以太坊有兩種帳戶

  1. 外部擁有帳戶,英文為 EOA(Externally Owned Account),也就是一般有私鑰的錢包地址。
  2. 智能合約帳戶。

智能合約常會使用 msg.sender 來取得函式呼叫者,進一步作為在系統中的帳號,作為記帳之類的應用。EOA 的一般錢包可以直接發起交易來使用智能合約,而有時我們會使用另外的智能合約間接使用 dapp,例如多簽合約來管理,而這些智能合約帳戶本身無法自己發起交易,必須還是要有一般錢包來代為發送上鏈。

帳戶抽象 (Account Abstraction)

現行的帳戶機制存在一些問題,例如 EOA 帳戶私鑰必須保管好不能遺失,智能合約帳戶本身無法自行發起交易等等。帳戶抽象被提出就是為了解決相關的問題的機制,可以參考完整的文件 EIP-4337EIP-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;

// 為 Paymaster 執行事前準備
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,所以這邊先簡化官方文件的流程為下:

  1. 驗證 nonce 是否正確。
  2. 呼叫 validateTransaction() 驗證交易是否可執行,同時確認 nonce 標記為使用過。
  3. 呼叫 payForTransaction() 來支付手續費。
  4. 呼叫 executeTransaction() 執行交易。

前置準備

以下的範例原始碼放在 Github 上,可以直接參考。首先我們先實做一個簡單的智能合約,用來之後給抽象帳戶呼叫之用,

1
2
3
4
5
6
7
8
9
10
//SPDX-License-Identifier: Unlicense
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
// SPDX-License-Identifier: MIT
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
// SPDX-License-Identifier: MIT
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;

// 某些方法限制只有 bootloader 能呼叫
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) {
// 使用掉 Nonce
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);
}

// 使用官方的 EfficientCall 方式處理
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 {
// bootloader 不應該會呼叫
assert(msg.sender != BOOTLOADER_FORMAL_ADDRESS);
}

// 必須要能收 ETH
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
// SPDX-License-Identifier: MIT
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);

// 建立 Factory
const factory = await deployer.deploy(
factoryArtifact,
[utils.hashBytecode(accountArtifact.bytecode)],
undefined,
[accountArtifact.bytecode]
);

// 建立一個錢包
const owner = Wallet.createRandom();

// 產生 salt 亂數, 這邊範例使用固定的 0
const salt = ethers.constants.HashZero;

// 產生抽象帳戶
(await factory.deployAccount(salt, owner.address)).wait();

// 由於 deployAccount() 回傳的地址沒辦法直接拿到,這裡使用鏈下預測生成的地址,實務上也可用 event 之類的方式丟出來
const abiCoder = new ethers.utils.AbiCoder();
const accountAddress = utils.create2Address(
factory.address,
await factory.aaBytecodeHash(),
salt,
abiCoder.encode(['address'], [owner.address])
);

// 發送一點 ETH 給這個帳戶當 Gas
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
// 讓這個抽象帳戶去呼叫 hello 合約的 hi() 函式
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)
};

// owner 簽署這個交易
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(); // 這裡應該會是 accountAddress

原本呼叫合約的方法是用 hello.connect(wallet).hi(); 方式直接送出,這邊要改成使用 populateTransaction 產生 tx 內容,然後在 tx 內容多加上一些抽象帳戶特有的資訊,例如 typecustomData 的資料。

如果要實作多簽,可以改為多個 owner 進行簽署,把多個簽章結合起來帶入 customSignature,最後在合約中檢驗簽章的時候把 _transaction.signature 切開來檢驗。可以參考官方範例

實測結果

這是一個實際部署到測試鏈的抽象帳戶,在區塊瀏覽器上可以看到它看起來是一個 Contract,而下方的交易紀錄可以看到他能夠主動發起交易:
zkSync 帳戶抽象 (Account Abstraction)

延伸閱讀

使用 Ethers.js 操作 zkSync 帳戶抽象
使用 Web3.js 操作 zkSync 帳戶抽象
Solidity - zkSync Paymaster 程式範例