在之前的文章 zkSync 帳戶抽象 (Account Abstraction) 程式範例說明了如何撰寫和部署帳戶抽象合約,也有使用抽象帳戶發起交易的方法。不過專案是用 zksync-cli 建立,使用的是 Ethers.js v5 的版本。這篇文章會說明如何在 Web3.js 的專案中使用,本篇文章使用 Web3.js 4.2.2 版本。

zksync 套件

zkSync 提供了官方的套件 zksync2-js 可以用來處理帳戶抽象相關的交易。但是該套件使用 Ethers.js 做為底層來開發,使用這套件等於要同時使用 Ethers.js。我這裡依據官方原始碼改寫了一份 Web3.js 版本的,可直接加入專案使用,就不用和 Ethers.js 混合使用。例如儲存為 eip712-signer.ts:

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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import Web3, { Transaction, utils, Bytes, Numbers, } from 'web3';
import { signTypedData, SignTypedDataVersion } from '@metamask/eth-sig-util';
import { RLP } from '@ethereumjs/rlp';
import { sha256 } from 'ethereum-cryptography/sha256';

export const DEFAULT_GAS_PER_PUBDATA_LIMIT = 50_000;
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
const EIP712_TX_TYPE = 0x71;
const MAX_BYTECODE_LEN_BYTES = ((1 << 16) - 1) * 32;
const eip712Types = {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' }
],
Transaction: [
{ name: 'txType', type: 'uint256' },
{ name: 'from', type: 'uint256' },
{ name: 'to', type: 'uint256' },
{ name: 'gasLimit', type: 'uint256' },
{ name: 'gasPerPubdataByteLimit', type: 'uint256' },
{ name: 'maxFeePerGas', type: 'uint256' },
{ name: 'maxPriorityFeePerGas', type: 'uint256' },
{ name: 'paymaster', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'value', type: 'uint256' },
{ name: 'data', type: 'bytes' },
{ name: 'factoryDeps', type: 'bytes32[]' },
{ name: 'paymasterInput', type: 'bytes' }
],
};

export interface Signature {
r: string;
s: string;
v: string;
};

export interface TransactionRequest extends Transaction {
customData?: any;
}

function toBeArray(value: Numbers) {
const hex = value ? utils.numberToHex(value, true) : '0x';
return utils.hexToBytes(hex);
}

function zeroPad(data: Bytes, length: number, left: boolean): string {
const bytes = utils.bytesToUint8Array(data);
if (length < bytes.length) {
throw new Error('');
}

const result = new Uint8Array(length);
result.fill(0);
if (left) {
result.set(bytes, length - bytes.length);
} else {
result.set(bytes, 0);
}

return utils.bytesToHex(result);
}

function hashBytecode(bytecode: Bytes): Uint8Array {
// For getting the consistent length we first convert the bytecode to UInt8Array
const bytecodeAsArray = utils.bytesToUint8Array(bytecode);

if (bytecodeAsArray.length % 32 != 0) {
throw new Error('The bytecode length in bytes must be divisible by 32');
}

if (bytecodeAsArray.length > MAX_BYTECODE_LEN_BYTES) {
throw new Error(`Bytecode can not be longer than ${MAX_BYTECODE_LEN_BYTES} bytes`);
}

const hash = sha256(bytecodeAsArray);

// Note that the length of the bytecode
// should be provided in 32-byte words.
const bytecodeLengthInWords = bytecodeAsArray.length / 32;
if (bytecodeLengthInWords % 2 == 0) {
throw new Error('Bytecode length in 32-byte words must be odd');
}

const bytecodeLength = toBeArray(bytecodeLengthInWords);

// The bytecode should always take the first 2 bytes of the bytecode hash,
// so we pad it from the left in case the length is smaller than 2 bytes.
const bytecodeLengthPadded = utils.bytesToUint8Array(zeroPad(bytecodeLength, 2, true));

const codeHashVersion = new Uint8Array([1, 0]);
hash.set(codeHashVersion, 0);
hash.set(bytecodeLengthPadded, 2);

return hash;
}

export function serializeEip712(transaction: TransactionRequest, signature?: Signature): string {
if (!transaction.chainId) {
throw Error("Transaction chainId isn't set");
}

if (!transaction.from) {
throw new Error('Explicitly providing `from` field is required for EIP712 transactions');
}
const from = transaction.from;
const meta: any = transaction.customData ?? {};
let maxFeePerGas = transaction.maxFeePerGas || transaction.gasPrice || 0;
let maxPriorityFeePerGas = transaction.maxPriorityFeePerGas || maxFeePerGas;

const fields: any[] = [
toBeArray(transaction.nonce || 0),
toBeArray(maxPriorityFeePerGas),
toBeArray(maxFeePerGas),
toBeArray(transaction.gasLimit || 0),
transaction.to != null ? utils.toChecksumAddress(transaction.to) : '0x',
toBeArray(transaction.value || 0),
transaction.data || '0x',
];

if (signature) {
fields.push(signature.v === '0x27' ? 0 : 1);
fields.push(toBeArray(signature.r));
fields.push(toBeArray(signature.s));
} else {
fields.push(toBeArray(transaction.chainId));
fields.push('0x');
fields.push('0x');
}
fields.push(toBeArray(transaction.chainId));
fields.push(utils.toChecksumAddress(from));

// Add meta
fields.push(toBeArray(meta.gasPerPubdata || DEFAULT_GAS_PER_PUBDATA_LIMIT));
fields.push((meta.factoryDeps ?? []).map((dep: any) => utils.bytesToHex(dep)));

if (meta.customSignature && utils.bytesToUint8Array(meta.customSignature).length == 0) {
throw new Error('Empty signatures are not supported');
}
fields.push(meta.customSignature || '0x');

if (meta.paymasterParams) {
fields.push([
meta.paymasterParams.paymaster,
utils.bytesToHex(meta.paymasterParams.paymasterInput),
]);
} else {
fields.push([]);
}

return utils.bytesToHex(utils.uint8ArrayConcat(new Uint8Array([EIP712_TX_TYPE]), RLP.encode(fields)));
}

export class EIP712Signer {
private eip712Domain: any;

constructor(private web3OrPrivateKey: Web3 | Buffer, chainId: number) {
this.eip712Domain = {
name: 'zkSync',
version: '2',
chainId,
};
}

static getSignInput(transaction: TransactionRequest) {
const maxFeePerGas = transaction.maxFeePerGas || transaction.gasPrice;
const maxPriorityFeePerGas = transaction.maxPriorityFeePerGas || maxFeePerGas;
const gasPerPubdataByteLimit = transaction.customData?.gasPerPubdata || DEFAULT_GAS_PER_PUBDATA_LIMIT;
return {
txType: transaction.type,
from: transaction.from,
to: transaction.to,
gasLimit: transaction.gasLimit,
gasPerPubdataByteLimit: gasPerPubdataByteLimit,
maxFeePerGas,
maxPriorityFeePerGas,
paymaster: transaction.customData?.paymasterParams?.paymaster || ZERO_ADDRESS,
nonce: transaction.nonce,
value: transaction.value,
data: transaction.data,
factoryDeps: transaction.customData?.factoryDeps?.map((dep: any) => hashBytecode(dep)) || [],
paymasterInput: transaction.customData?.paymasterParams?.paymasterInput || '0x',
};
}

async sign(transaction: TransactionRequest): Promise<string> {
const typedData = {
types: eip712Types,
domain: this.eip712Domain,
primaryType: 'Transaction',
message: EIP712Signer.getSignInput(transaction)
};
return this.web3OrPrivateKey instanceof Web3 ? this.signByWeb3(typedData) : this.signByPrivateKey(typedData);
}

private async signByWeb3(typedData: any): Promise<string> {
const web3 = this.web3OrPrivateKey as Web3
const accounts = await web3.eth.getAccounts();
return web3.eth.signTypedData(accounts[0], typedData);
}

private async signByPrivateKey(typedData: any): Promise<string> {
return signTypedData({
privateKey: this.web3OrPrivateKey as Buffer,
data: typedData as any,
version: SignTypedDataVersion.V4
});
}
}

需要額外安裝套件 @metamask/eth-sig-util

1
npm install --save @metamask/eth-sig-util

產生 Tx 物件

呼叫合約

原本使用以下方式直接送出:

1
await contract.methods.method().send({ from, /* ... */ });

要改成產生 Tx 物件,再進行下一步操作

1
2
3
4
5
let tx: any = {
data: contract.methods.method().encodeABI(),
from: from,
to: contract.options.address
};

然後加上一些資訊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { DEFAULT_GAS_PER_PUBDATA_LIMIT, EIP712Signer, serializeEip712 } from './eip712-signer';

// 抽象帳戶地址
const accountAddress = '0x...';

tx = {
...tx,
from: accountAddress,
// 這裡預估不一定正確, 也可以直接填一個數值
// BigInt 型別會出錯,這裡都轉成字串
gasLimit: (await web3.eth.estimateGas(aaTx)).toString(),
gasPrice: (await web3.eth.getGasPrice()).toString(),
chainId: (await web3.eth.getChainId()).toString(),
nonce: (await web3.eth.getTransactionCount(accountAddress)).toString(),
type: 113,
customData: {
gasPerPubdata: DEFAULT_GAS_PER_PUBDATA_LIMIT,
},
value: 0
};

傳送 Ether

抽象帳戶要傳送 Ether 可以用下面方式產生 tx 物件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let tx = {
data: '0x',
to: '0x...', // 目標地址
from: accountAddress,
gasLimit: 200000, // 無法預估,直接填入
gasPrice: (await web3.eth.getGasPrice()).toString(),
chainId: (await web3.eth.getChainId()).toString(),
nonce: (await web3.eth.getTransactionCount(accountAddress)).toString(),
type: 113,
customData: {
gasPerPubdata: DEFAULT_GAS_PER_PUBDATA_LIMIT,
},
value: web3.utils.toWei(0.001, 'ether')
};

簽章

支援私鑰或瀏覽器錢包進行簽章,私鑰要先轉成 Buffer:

1
let web3OrPrivateKey = Buffer.from('Private Key', 'hex')

瀏覽器錢包直接使用連接好的 web3 物件就可以了

1
let web3OrPrivateKey = web3;

接著就可以對 tx 物件進行簽章

1
2
3
4
5
const eip712Signer = new EIP712Signer(web3OrPrivateKey, tx.chainId!);
tx.customData = {
...tx.customData,
customSignature: await eip712Signer.sign(tx)
};

在 Metamask 簽署的時候會看到類似下面的畫面
Metamask 簽署 zkSync 帳戶抽象交易

送出交易

最後要將準備好的 tx 送出到鏈上。

1
2
3
4
5
6
const txHash = await web3.currentProvider!.request(
{
method: 'eth_sendRawTransaction',
params: [serializeEip712(aaTx)]
}
);

延伸閱讀

zkSync 帳戶抽象 (Account Abstraction) 程式範例
使用 Web3.js 操作 zkSync Paymaster
使用 Ethers.js 操作 zkSync 帳戶抽象