Nhảy đến nội dung chính

Các ABI ngắn để tối ưu hóa Calldata

lớp 2
Trung gian
Ori Pomerantz
1 tháng 4, 2022
17 số phút đọc

Giới thiệu

Trong bài viết này, bạn sẽ tìm hiểu về các gộp giao dịch lạc quan, chi phí giao dịch trên chúng và cách cấu trúc chi phí khác biệt đó đòi hỏi chúng ta phải tối ưu hóa cho những thứ khác so với trên Mạng chính Ethereum. Bạn cũng sẽ học cách thực hiện tối ưu hóa này.

Tiết lộ đầy đủ

Tôi là nhân viên toàn thời gian của Optimism (opens in a new tab), vì vậy các ví dụ trong bài viết này sẽ chạy trên Optimism. Tuy nhiên, kỹ thuật được giải thích ở đây cũng sẽ hoạt động tốt cho các rollup khác.

Thuật ngữ

Khi thảo luận về các rollup, thuật ngữ 'lớp 1' (L1) được sử dụng cho Mạng chính, mạng Ethereum sản phẩm. Thuật ngữ 'lớp 2' (L2) được sử dụng cho rollup hoặc bất kỳ hệ thống nào khác dựa vào L1 để bảo mật nhưng thực hiện hầu hết quá trình xử lý của nó ngoài chuỗi.

Làm cách nào chúng ta có thể giảm thêm chi phí giao dịch L2?

Các gộp giao dịch lạc quan phải lưu giữ bản ghi của mọi giao dịch trong lịch sử để bất kỳ ai cũng có thể xem qua chúng và xác minh rằng trạng thái hiện tại là chính xác. Cách rẻ nhất để đưa dữ liệu vào Mạng chính Ethereum là ghi dữ liệu đó dưới dạng calldata. Giải pháp này đã được cả Optimism (opens in a new tab)Arbitrum (opens in a new tab) lựa chọn.

Chi phí giao dịch L2

Chi phí giao dịch L2 bao gồm hai thành phần:

  1. Xử lý L2, thường cực kỳ rẻ
  2. Lưu trữ L1, gắn liền với chi phí gas của Mạng chính

Khi tôi đang viết bài này, trên Optimism, chi phí gas L2 là 0,001 Gwei. Mặt khác, chi phí gas L1 là khoảng 40 gwei. Bạn có thể xem giá hiện tại tại đây (opens in a new tab).

Một byte calldata có giá 4 gas (nếu là số không) hoặc 16 gas (nếu là bất kỳ giá trị nào khác). Một trong những hoạt động tốn kém nhất trên máy ảo ethereum (EVM) là ghi vào bộ nhớ lưu trữ. Chi phí tối đa để ghi một từ 32 byte vào bộ nhớ lưu trữ trên L2 là 22100 gas. Hiện tại, chi phí này là 22,1 gwei. Vì vậy, nếu chúng ta có thể tiết kiệm một byte calldata bằng không, chúng ta sẽ có thể ghi khoảng 200 byte vào bộ nhớ lưu trữ mà vẫn có lợi.

Giao diện nhị phân ứng dụng (ABI)

Phần lớn các giao dịch truy cập vào một hợp đồng từ một tài khoản sở hữu ngoại biên. Hầu hết các hợp đồng được viết bằng Solidity và diễn giải trường dữ liệu của chúng theo giao diện nhị phân ứng dụng (ABI) (opens in a new tab).

Tuy nhiên, ABI được thiết kế cho L1, nơi một byte calldata có giá xấp xỉ bốn phép toán số học, chứ không phải L2, nơi một byte calldata có giá hơn một nghìn phép toán số học. Calldata được chia như sau:

PhầnĐộ dàiByteByte lãng phíGas lãng phíByte cần thiếtGas cần thiết
Bộ chọn hàm40-3348116
Các số không124-15124800
Địa chỉ đích2016-350020320
Số lượng3236-67176415240
Tổng68160576

Giải thích:

  • Bộ chọn hàm: Hợp đồng có ít hơn 256 hàm, vì vậy chúng ta có thể phân biệt chúng bằng một byte duy nhất. Các byte này thường khác không và do đó có giá mười sáu gas (opens in a new tab).
  • Các số không: Các byte này luôn bằng không vì một địa chỉ hai mươi byte không yêu cầu một từ ba mươi hai byte để chứa nó. Các byte chứa giá trị không có giá bốn gas (xem sách vàng (opens in a new tab), Phụ lục G, tr. 27, giá trị cho Gtxdatazero).
  • Số lượng: Nếu chúng ta giả định rằng trong hợp đồng này, decimals là mười tám (giá trị bình thường) và số lượng token tối đa chúng ta chuyển sẽ là 1018, chúng ta sẽ có số lượng tối đa là 1036. 25615 > 1036, vì vậy mười lăm byte là đủ.

Lãng phí 160 gas trên L1 thường không đáng kể. Một giao dịch có giá ít nhất 21.000 gas (opens in a new tab), vì vậy thêm 0,8% không thành vấn đề. Tuy nhiên, trên L2, mọi thứ lại khác. Hầu như toàn bộ chi phí của giao dịch là ghi nó vào L1. Ngoài calldata giao dịch, có 109 byte tiêu đề giao dịch (địa chỉ đích, chữ ký, v.v.). Do đó, tổng chi phí là 109*16+576+160=2480, và chúng ta đang lãng phí khoảng 6,5% trong số đó.

Giảm chi phí khi bạn không kiểm soát đích đến

Giả sử rằng bạn không có quyền kiểm soát hợp đồng đích, bạn vẫn có thể sử dụng một giải pháp tương tự như giải pháp này (opens in a new tab). Hãy xem qua các tệp có liên quan.

Token.sol

Đây là hợp đồng đích (opens in a new tab). Đó là một hợp đồng ERC-20 tiêu chuẩn, với một tính năng bổ sung. Hàm faucet này cho phép bất kỳ người dùng nào nhận được một số token để sử dụng. Điều này sẽ làm cho một hợp đồng ERC-20 sản phẩm trở nên vô dụng, nhưng nó giúp cuộc sống dễ dàng hơn khi một ERC-20 chỉ tồn tại để tạo điều kiện thử nghiệm.

1 /**
2 * @dev Cung cấp cho người gọi 1000 token để sử dụng
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

CalldataInterpreter.sol

Đây là hợp đồng mà các giao dịch sẽ gọi với calldata ngắn hơn (opens in a new tab). Hãy xem xét từng dòng một.

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";

Chúng ta cần hàm token để biết cách gọi nó.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

Địa chỉ của token mà chúng tôi là một proxy.

1
2 /**
3 * @dev Chỉ định địa chỉ token
4 * @param tokenAddr_ địa chỉ hợp đồng ERC-20
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Hiện tất cả

Địa chỉ token là tham số duy nhất chúng ta cần chỉ định.

1 function calldataVal(uint startByte, uint length)
2 private pure returns (uint) {

Đọc một giá trị từ calldata.

1 uint _retVal;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal trying to read beyond calldatasize");

Chúng ta sẽ tải một từ 32 byte (256-bit) vào bộ nhớ và loại bỏ các byte không phải là một phần của trường chúng ta muốn. Thuật toán này không hoạt động đối với các giá trị dài hơn 32 byte, và tất nhiên chúng ta không thể đọc vượt quá cuối calldata. Trên L1, có thể cần bỏ qua các bài kiểm tra này để tiết kiệm gas, nhưng trên L2, gas cực kỳ rẻ, cho phép chúng ta thực hiện bất kỳ kiểm tra tính hợp lệ nào có thể nghĩ đến.

1 assembly {
2 _retVal := calldataload(startByte)
3 }

Chúng ta có thể sao chép dữ liệu từ lệnh gọi đến fallback() (xem bên dưới), nhưng việc sử dụng Yul (opens in a new tab), ngôn ngữ hợp ngữ của máy ảo ethereum (EVM), sẽ dễ dàng hơn.

Ở đây chúng ta sử dụng opcode CALLDATALOAD (opens in a new tab) để đọc các byte từ startByte đến startByte+31 vào ngăn xếp. Nói chung, cú pháp của một opcode trong Yul là <tên opcode>(<giá trị ngăn xếp đầu tiên, nếu có>,<giá trị ngăn xếp thứ hai, nếu có>...).

1
2 _retVal = _retVal >> (256-length*8);

Chỉ các byte length quan trọng nhất là một phần của trường, vì vậy chúng ta dịch phải (opens in a new tab) để loại bỏ các giá trị khác. Điều này có thêm lợi thế là di chuyển giá trị sang bên phải của trường, vì vậy nó là chính giá trị đó thay vì giá trị nhân với 256lần nào đó.

1
2 return _retVal;
3 }
4
5
6 fallback() external {

Khi một lệnh gọi đến hợp đồng Solidity không khớp với bất kỳ chữ ký hàm nào, nó sẽ gọi hàm fallback() (opens in a new tab) (giả sử có một hàm). Trong trường hợp của CalldataInterpreter, bất kỳ lệnh gọi nào cũng sẽ đến đây vì không có hàm external hoặc public nào khác.

1 uint _func;
2
3 _func = calldataVal(0, 1);

Đọc byte đầu tiên của calldata, cho chúng ta biết hàm. Có hai lý do tại sao một hàm sẽ không có sẵn ở đây:

  1. Các hàm pure hoặc view không thay đổi trạng thái và không tốn gas (khi được gọi ngoài chuỗi). Không có ý nghĩa gì khi cố gắng giảm chi phí gas của chúng.
  2. Các hàm dựa trên msg.sender (opens in a new tab). Giá trị của msg.sender sẽ là địa chỉ của CalldataInterpreter, không phải là người gọi.

Thật không may, nhìn vào các thông số kỹ thuật ERC-20 (opens in a new tab), điều này chỉ còn lại một hàm, transfer. Điều này chỉ còn lại hai hàm: transfer (bởi vì chúng ta có thể gọi transferFrom) và faucet (bởi vì chúng ta có thể chuyển token trở lại cho bất kỳ ai đã gọi chúng ta).

1
2 // Gọi các phương thức thay đổi trạng thái của token bằng
3 // thông tin từ calldata
4
5 // faucet
6 if (_func == 1) {

Một lệnh gọi đến faucet(), không có tham số.

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }

Sau khi chúng ta gọi token.faucet(), chúng ta nhận được token. Tuy nhiên, với tư cách là hợp đồng proxy, chúng tôi không cần token. EOA (tài khoản sở hữu bên ngoài) hoặc hợp đồng đã gọi chúng tôi thì cần. Vì vậy, chúng tôi chuyển tất cả token của mình cho bất kỳ ai đã gọi chúng tôi.

1 // chuyển khoản (giả sử chúng ta có khoản cho phép cho nó)
2 if (_func == 2) {

Chuyển token yêu cầu hai tham số: địa chỉ đích và số lượng.

1 token.transferFrom(
2 msg.sender,

Chúng tôi chỉ cho phép người gọi chuyển token mà họ sở hữu

1 address(uint160(calldataVal(1, 20))),

Địa chỉ đích bắt đầu ở byte #1 (byte #0 là hàm). Là một địa chỉ, nó dài 20 byte.

1 calldataVal(21, 2)

Đối với hợp đồng cụ thể này, chúng tôi giả định rằng số lượng token tối đa mà bất kỳ ai muốn chuyển sẽ vừa trong hai byte (ít hơn 65536).

1 );
2 }

Nhìn chung, một lần chuyển khoản mất 35 byte calldata:

PhầnĐộ dàiByte
Bộ chọn hàm10
Địa chỉ đích321-32
Số lượng233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.js

Thử nghiệm đơn vị JavaScript này (opens in a new tab) cho chúng ta thấy cách sử dụng cơ chế này (và cách xác minh nó hoạt động chính xác). Tôi sẽ giả định bạn hiểu chai (opens in a new tab)ethers (opens in a new tab) và chỉ giải thích các phần áp dụng cụ thể cho hợp đồng.

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Should let us use tokens", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Token addr:", token.address)
9
10 const Cdi = await ethers.getContractFactory("CalldataInterpreter")
11 const cdi = await Cdi.deploy(token.address)
12 await cdi.deployed()
13 console.log("CalldataInterpreter addr:", cdi.address)
14
15 const signer = await ethers.getSigner()
Hiện tất cả

Chúng tôi bắt đầu bằng cách triển khai cả hai hợp đồng.

1 // Nhận token để sử dụng
2 const faucetTx = {

Chúng ta không thể sử dụng các hàm cấp cao mà chúng ta thường sử dụng (chẳng hạn như token.faucet()) để tạo giao dịch, vì chúng ta không tuân theo ABI. Thay vào đó, chúng ta phải tự xây dựng giao dịch và sau đó gửi nó.

1 to: cdi.address,
2 data: "0x01"

Có hai tham số chúng ta cần cung cấp cho giao dịch:

  1. to, địa chỉ đích. Đây là hợp đồng thông dịch calldata.
  2. data, calldata cần gửi. Trong trường hợp gọi faucet, dữ liệu là một byte duy nhất, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

Chúng tôi gọi phương thức sendTransaction của người ký (opens in a new tab) vì chúng tôi đã chỉ định đích (faucetTx.to) và chúng tôi cần giao dịch được ký.

1// Kiểm tra faucet cung cấp token chính xác
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Ở đây chúng tôi xác minh số dư. Không cần tiết kiệm gas cho các hàm view, vì vậy chúng tôi chỉ chạy chúng bình thường.

1// Cấp cho CDI một khoản cho phép (không thể ủy quyền phê duyệt)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Cấp cho trình thông dịch calldata một khoản trợ cấp để có thể thực hiện chuyển khoản.

1// Chuyển token
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

Tạo một giao dịch chuyển khoản. Byte đầu tiên là "0x02", theo sau là địa chỉ đích và cuối cùng là số tiền (0x0100, tức là 256 trong hệ thập phân).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Kiểm tra xem chúng ta có ít hơn 256 token
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // Và đích đến của chúng ta đã nhận được chúng
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Hiện tất cả

Giảm chi phí khi bạn kiểm soát hợp đồng đích

Nếu bạn có quyền kiểm soát hợp đồng đích, bạn có thể tạo các hàm bỏ qua kiểm tra msg.sender vì chúng tin tưởng vào trình thông dịch calldata. Bạn có thể xem một ví dụ về cách hoạt động của nó tại đây, trong nhánh control-contract (opens in a new tab).

Nếu hợp đồng chỉ phản hồi các giao dịch bên ngoài, chúng ta có thể chỉ cần một hợp đồng duy nhất. Tuy nhiên, điều đó sẽ phá vỡ tính kết hợp. Tốt hơn nhiều là có một hợp đồng phản hồi các lệnh gọi ERC-20 bình thường và một hợp đồng khác phản hồi các giao dịch với dữ liệu cuộc gọi ngắn.

Token.sol

Trong ví dụ này, chúng ta có thể sửa đổi Token.sol. Điều này cho phép chúng ta có một số hàm mà chỉ proxy mới có thể gọi. Dưới đây là các phần mới:

1 // Địa chỉ duy nhất được phép chỉ định địa chỉ CalldataInterpreter
2 address owner;
3
4 // Địa chỉ CalldataInterpreter
5 address proxy = address(0);

Hợp đồng ERC-20 cần biết danh tính của proxy được ủy quyền. Tuy nhiên, chúng ta không thể đặt biến này trong hàm khởi tạo, vì chúng ta chưa biết giá trị của nó. Hợp đồng này được khởi tạo trước vì proxy mong đợi địa chỉ của token trong hàm tạo của nó.

1 /**
2 * @dev Gọi hàm khởi tạo ERC20.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }

Địa chỉ của người tạo (được gọi là owner) được lưu trữ ở đây vì đó là địa chỉ duy nhất được phép đặt proxy.

1 /**
2 * @dev đặt địa chỉ cho proxy (CalldataInterpreter).
3 * Chỉ có thể được gọi một lần bởi chủ sở hữu
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Can only be called by owner");
7 require(proxy == address(0), "Proxy is already set");
8
9 proxy = _proxy;
10 } // function setProxy
Hiện tất cả

Proxy có quyền truy cập đặc quyền, vì nó có thể bỏ qua các kiểm tra bảo mật. Để đảm bảo rằng chúng ta có thể tin tưởng vào proxy, chúng tôi chỉ cho phép owner gọi hàm này và chỉ một lần. Khi proxy có giá trị thực (khác không), giá trị đó không thể thay đổi, vì vậy ngay cả khi chủ sở hữu quyết định trở thành kẻ xấu, hoặc cụm từ ghi nhớ của nó bị tiết lộ, chúng ta vẫn an toàn.

1 /**
2 * @dev Một số hàm chỉ có thể được gọi bởi proxy.
3 */
4 modifier onlyProxy {

Đây là một hàm modifier (opens in a new tab), nó sửa đổi cách hoạt động của các hàm khác.

1 require(msg.sender == proxy);

Đầu tiên, xác minh chúng tôi được gọi bởi proxy chứ không phải ai khác. Nếu không, hãy revert.

1 _;
2 }

Nếu vậy, hãy chạy hàm mà chúng tôi sửa đổi.

1 /* Các hàm cho phép proxy thực sự làm proxy cho các tài khoản */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Hiện tất cả

Đây là ba hoạt động thường yêu cầu thông điệp phải đến trực tiếp từ thực thể chuyển token hoặc phê duyệt một khoản trợ cấp. Ở đây chúng tôi có một phiên bản proxy của các hoạt động này:

  1. Được sửa đổi bởi onlyProxy() để không ai khác được phép kiểm soát chúng.
  2. Nhận địa chỉ thường là msg.sender làm tham số bổ sung.

CalldataInterpreter.sol

Trình thông dịch calldata gần như giống hệt với trình thông dịch ở trên, ngoại trừ việc các hàm được ủy quyền nhận tham số msg.sender và không cần có khoản cho phép transfer.

1 // chuyển khoản (không cần khoản cho phép)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // phê duyệt
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 if (_func == 4) {
21 token.transferFromProxy(
22 msg.sender,
23 address(uint160(calldataVal( 1, 20))),
24 address(uint160(calldataVal(21, 20))),
25 calldataVal(41, 2)
26 );
27 }
Hiện tất cả

Test.js

Có một vài thay đổi giữa mã kiểm tra trước đó và mã này.

1const Cdi = await ethers.getContractFactory("CalldataInterpreter")
2const cdi = await Cdi.deploy(token.address)
3await cdi.deployed()
4await token.setProxy(cdi.address)

Chúng ta cần cho hợp đồng ERC-20 biết proxy nào cần tin tưởng

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Cần hai người ký để xác minh các khoản cho phép
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

Để kiểm tra approve()transferFrom(), chúng ta cần một người ký thứ hai. Chúng tôi gọi nó là poorSigner vì nó không nhận được bất kỳ token nào của chúng tôi (tất nhiên nó cần phải có ETH).

1// Chuyển token
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}
7await (await signer.sendTransaction(transferTx)).wait()

Bởi vì hợp đồng ERC-20 tin tưởng vào proxy (cdi), chúng ta không cần một khoản trợ cấp để chuyển tiếp các giao dịch chuyển khoản.

1// phê duyệt và transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const transferFromTx = {
11 to: cdi.address,
12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
13}
14await (await poorSigner.sendTransaction(transferFromTx)).wait()
15
16// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Hiện tất cả

Kiểm tra hai chức năng mới. Lưu ý rằng transferFromTx yêu cầu hai tham số địa chỉ: người cấp khoản cho phép và người nhận.

Kết luận

Cả Optimism (opens in a new tab)Arbitrum (opens in a new tab) đều đang tìm cách giảm kích thước của calldata được ghi vào L1 và do đó giảm chi phí giao dịch. Tuy nhiên, với tư cách là các nhà cung cấp cơ sở hạ tầng đang tìm kiếm các giải pháp chung, khả năng của chúng tôi bị hạn chế. Với tư cách là nhà phát triển ứng dụng phi tập trung, bạn có kiến thức cụ thể về ứng dụng, cho phép bạn tối ưu hóa calldata của mình tốt hơn nhiều so với chúng tôi trong một giải pháp chung. Hy vọng rằng, bài viết này sẽ giúp bạn tìm ra giải pháp lý tưởng cho nhu cầu của mình.

Xem thêm công việc của tôi tại đây (opens in a new tab).

Lần cập nhật trang lần cuối: 22 tháng 8, 2025

Hướng dẫn này có hữu ích không?