之前文章 zkSync 帳戶抽象 (Account Abstraction) 程式範例說明了 zkSync Era 帳戶抽象的用法,這篇繼續說明另一個 EIP-4337 的擴充功能 - Paymaster。這在 zkSync Era 中也有原生的支持。雖然是帳戶抽象中的擴充功能,實際上並不是只有抽象帳戶才能使用,EOA 帳戶也能使用,是一個獨立的模組。

說明

Paymaster 主要的功能是能自訂交易發起人的 Gas 手續費支付方式。以往錢包需要 ETH 才能發送交易,透過 Paymaster 的機制可以解決這問題,可以用各種自訂的方式來代為支付 Gas。例如使用 USDC 等代幣來支付,甚至是免費代為支付。

運作機制是需要先使用智能合約實作 Paymaster,在合約中定義支付的方式,最後在發起的交易 Tx 中指定要使用的 Paymaster。

要實作一個 Paymaster,必須實作的介面如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface IPaymaster {
// 主要驗證和支付的邏輯
function validateAndPayForPaymasterTransaction(
bytes32 _txHash,
bytes32 _suggestedSignedHash,
Transaction calldata _transaction
) external payable returns (bytes4 magic, bytes memory context);

// 交易成功後執行的動作
function postTransaction(
bytes calldata _context,
Transaction calldata _transaction,
bytes32 _txHash,
bytes32 _suggestedSignedHash,
ExecutionResult _txResult,
uint256 _maxRefundedGas
) external payable;
}

介面可以參考官方 IPaymaster 原始碼

類型

在發送交易的時候依據可以帶入不同的資料在 customDatapaymasterParams 之中。
paymasterParams 包含:

  1. paymaster:Paymaster 合約地址。
  2. paymasterInput:包含類型和其他資訊的 bytes 資料,類似 calldata。

zksync 套件提供能生成 paymasterParams 的函式,所以這部分大概知道就好了,可以參考後面程式範例。

目前 Paymaster 有兩種類型:General 和 ApprovalBased。

Genernal

Genernal 類型的 paymasterInput 資料為如下的編碼:

1
function general(bytes calldata data);

例如

1
0x8c5a344500000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000

Genernal 類型的 EOA 帳號不會做任何額外的動作。

ApprovalBased

ApprovalBased 類型的 paymasterInput 資料為如下的編碼:

1
2
3
4
5
function approvalBased(
address _token,
uint256 _minAllowance,
bytes calldata _innerInput
)

例如

1
0x949431dc000000000000000000000000111c3e89ce80e62ee88318c2804920d4c96f92bb000000000000000000000000000000000000000000000000120eb850e8d7c00000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000

ApprovalBased 類型的 EOA 帳號會同時執行類似 _token.approve(paymaster, _minAllowance) 的動作,讓 Paymaster 後續能夠扣款。

可以參考 Paymaster 官方文件

前置準備

以下的範例原始碼放在 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

繼承 IPaymaster 介面開始實作:

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/IPaymaster.sol";

contract GeneralPaymaster is IPaymaster {
// ...
}

後面會實作 General 和 ApprovalBased 兩種範例。

範例

以下的範例原始碼都放在 Github 上。

General

合約

GeneralPaymaster 原始碼

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
// 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/IPaymaster.sol";
import "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol";

contract GeneralPaymaster is IPaymaster {
using Address for address payable;

// 某些方法限制只有 bootloader 能呼叫
modifier onlyBootloader() {
require(msg.sender == BOOTLOADER_FORMAL_ADDRESS, "Only bootloader can call this method");
_;
}

function validateAndPayForPaymasterTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
) external payable onlyBootloader returns (bytes4 magic, bytes memory context) {
// 目前固定回傳這個值,後面直接用 require 或 revert 做其他驗證
magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;
require(
_transaction.paymasterInput.length >= 4,
"The standard paymaster input must be at least 4 bytes long"
);

bytes4 paymasterInputSelector = bytes4(_transaction.paymasterInput[0:4]);
if (paymasterInputSelector == IPaymasterFlow.general.selector) {
// 計算要多少 ETH 當作 Gas 費
uint256 requiredETH = _transaction.gasLimit * _transaction.maxFeePerGas;

// 支付 Gas 手續費
payable(BOOTLOADER_FORMAL_ADDRESS).sendValue(requiredETH);
} else {
revert("Unsupported paymaster flow in paymasterParams.");
}
}

function postTransaction(
bytes calldata,
Transaction calldata,
bytes32,
bytes32,
ExecutionResult,
uint256
) external payable override onlyBootloader {}

// 必須要能收 ETH
receive() external payable {}
}

部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const helloArtifact = await deployer.loadArtifact('Hello');
const paymasterArtifact = await deployer.loadArtifact('GeneralPaymaster');

// 建立測試合約
const hello = await deployer.deploy(helloArtifact);

// 建立 Paymaster
const paymaster = await deployer.deploy(paymasterArtifact);

// 發送一點 ETH 給 Paymaster 當 Gas
await (
await wallet.sendTransaction({
to: paymaster.address,
value: ethers.utils.parseEther('0.01')
})
).wait();

// 建立一個沒有 ETH 的錢包
const owner = Wallet.createRandom().connect(provider);

測試

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
// 產生 Paymaster 參數
const paymasterParams = utils.getPaymasterParams(paymaster.address, {
type: 'General',
innerInput: '0x'
});

// 預估 Gas
const gasLimit = await hello.connect(owner).estimateGas.hi(
{
customData: {
paymasterParams,
gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT
}
}
);
const gasPrice = await provider.getGasPrice();

await (
await hello
.connect(owner)
.hi({
gasPrice,
gasLimit,
customData: {
paymasterParams,
gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
}
})
).wait();

const caller = await hello.caller(); // 這裡應該會是 owner.address

ApprovalBased

合約

真的要應用的話必須接上報價機制,參考官方使用 API3 範例。這裡只簡單示範,ApprovalPaymaster 原始碼

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
// 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/IPaymaster.sol";
import "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol";

contract ApprovalPaymaster is IPaymaster {
using Address for address payable;
using SafeERC20 for IERC20;

// 假設 token 是 USD,以太價格為 2000 USD
uint256 public price = 2000;
address public allowedToken;

// 某些方法限制只有 bootloader 能呼叫
modifier onlyBootloader() {
require(msg.sender == BOOTLOADER_FORMAL_ADDRESS, "Only bootloader can call this method");
_;
}

constructor(address _erc20) {
allowedToken = _erc20;
}

function validateAndPayForPaymasterTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
) external payable onlyBootloader returns (bytes4 magic, bytes memory context) {
// 目前固定回傳這個值,後面直接用 require 或 revert 做其他驗證
magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;
require(
_transaction.paymasterInput.length >= 4,
"The standard paymaster input must be at least 4 bytes long"
);

bytes4 paymasterInputSelector = bytes4(_transaction.paymasterInput[0:4]);
if (paymasterInputSelector == IPaymasterFlow.approvalBased.selector) {
// 此類型會帶的一些資料,可以自訂驗證方式,amount 是 user 授權的數字,可以視為 user 最多願意支付多少
(address token, uint256 amount, bytes memory data) = abi.decode(
_transaction.paymasterInput[4:],
(address, uint256, bytes)
);

// 檢驗是否支援使用此 Token 支付,合約可實作支援多種
require(token == allowedToken, "Invalid token");

// 計算要多少 ETH 當作 Gas 費
uint requiredETH = _transaction.gasLimit * _transaction.maxFeePerGas;

// 計算換成成 Token 要收取多少
uint requiredToken = requiredETH * price;
require(amount >= requiredToken, "Not the required amount of tokens sent");

// 收取 Token 作為 Gas 手續費
address userAddress = address(uint160(_transaction.from));
IERC20(token).safeTransferFrom(userAddress, address(this), requiredToken);

// 支付 Gas 手續費
payable(BOOTLOADER_FORMAL_ADDRESS).sendValue(requiredETH);
} else {
revert("Unsupported paymaster flow");
}
}

function postTransaction(
bytes calldata,
Transaction calldata,
bytes32,
bytes32,
ExecutionResult,
uint256
) external payable override onlyBootloader {}

// 必須要能收 ETH
receive() external payable {}
}

部署

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
const tokenArtifact = await deployer.loadArtifact('MyToken');
const helloArtifact = await deployer.loadArtifact('Hello');
const paymasterArtifact = await deployer.loadArtifact('ApprovalPaymaster');

// 建立 Token
const token = await deployer.deploy(tokenArtifact);

// 建立測試合約
const hello = await deployer.deploy(helloArtifact);

// 建立 Paymaster
const paymaster = await deployer.deploy(paymasterArtifact, [token.address]);

// 發送一點 ETH 給 Paymaster 當 Gas
await (
await wallet.sendTransaction({
to: paymaster.address,
value: ethers.utils.parseEther('0.01')
})
).wait();

// 建立一個沒有 ETH 的錢包
const owner = Wallet.createRandom().connect(provider);

// 發一點 Token 給 owner
await token.transfer(owner.address, ethers.utils.parseEther('1000'));

測試

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
// 產生 Paymaster 參數
const baseParams: any = {
type: 'ApprovalBased',
token: token.address,
innerInput: '0x'
};
let paymasterParams = utils.getPaymasterParams(paymaster.address, {
...baseParams,
minimalAllowance: ethers.utils.parseEther('30')
});

// 預估 Gas
const gasLimit = await hello.connect(owner).estimateGas.hi(
{
customData: {
paymasterParams,
gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT
}
}
);
const gasPrice = await provider.getGasPrice();

// 重新計算實際需要的 Token
paymasterParams = utils.getPaymasterParams(paymaster.address, {
...baseParams,
minimalAllowance: gasLimit.mul(gasPrice).mul(2000)
});

await (
await hello
.connect(owner)
.hi({
gasPrice,
gasLimit,
customData: {
paymasterParams: paymasterParams,
gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT
}
})
).wait();

const caller = await hello.caller(); // 這裡應該會是 owner.address

ApprovalBased + AA

上面兩個範例是使用 EOA 錢包,也可以和 zksync Era 的 AA 同時使用。合約部分參考上面和之前的文章。

部署

基本上就是把兩個範例混在一起

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
const tokenArtifact = await deployer.loadArtifact('MyToken');
const helloArtifact = await deployer.loadArtifact('Hello');
const factoryArtifact = await deployer.loadArtifact('MyFactory');
const accountArtifact = await deployer.loadArtifact('MyAccount');
const paymasterArtifact = await deployer.loadArtifact('ApprovalPaymaster');

// 建立 Token
const token = await deployer.deploy(tokenArtifact);

// 建立測試合約
const hello = await deployer.deploy(helloArtifact);

// 建立 Paymaster
const paymaster = await deployer.deploy(paymasterArtifact, [token.address]);

// 發送一點 ETH 給 Paymaster 當 Gas
await (
await wallet.sendTransaction({
to: paymaster.address,
value: ethers.utils.parseEther('0.01')
})
).wait();

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

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

// 產生 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])
);

// 發一點 Token 給抽象帳戶
await token.transfer(accountAddress, ethers.utils.parseEther('1000'));

測試

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
// 產生 Paymaster 參數
const baseParams: any = {
type: 'ApprovalBased',
token: token.address,
innerInput: '0x'
};
let paymasterParams = utils.getPaymasterParams(paymaster.address, {
...baseParams,
minimalAllowance: ethers.utils.parseEther('30')
});

// 預估 Gas
let aaTx = await hello.populateTransaction.hi({
customData: {
paymasterParams,
gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT
}
});
const gasLimit = await provider.estimateGas(aaTx);
const gasPrice = await provider.getGasPrice();

// 重新計算實際需要的 Token
paymasterParams = utils.getPaymasterParams(paymaster.address, {
...baseParams,
minimalAllowance: gasLimit.mul(gasPrice).mul(2000)
});

// 準備 Tx
aaTx = {
...aaTx,
from: accountAddress,
gasLimit,
gasPrice,
chainId: (await provider.getNetwork()).chainId,
nonce: await provider.getTransactionCount(accountAddress),
type: 113,
customData: {
paymasterParams,
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

帳戶抽象運作流程

之前文章的流程沒有使用 Paymaster,帳戶抽象加上使用 Paymaster 的流程為下:

  1. 驗證 nonce 是否正確。
  2. 呼叫 validateTransaction() 驗證交易是否可執行,同時確認 nonce 標記為使用過。
  3. 呼叫 prepareForPaymaster(),如果執行成功會,接著執行 Paymaster 的 validateAndPayForPaymasterTransaction(),成功會繼續往下。
  4. 呼叫 executeTransaction() 執行交易。

實測結果

這是一個測試鏈上抽象帳戶使用 Paymaster 的交易,在區塊瀏覽器上可以看到發起者支付 MT 代幣,而 ETH Gas 手續費則由 Paymaster 來支付:
Paymaster 交易

延伸閱讀

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