Short ABIs for Calldata Optimization
Pengantar
Dalam artikel ini, Anda akan belajar tentang optimistic rollup, biaya transaksi di dalamnya, dan bagaimana struktur biaya yang berbeda tersebut mengharuskan kita untuk melakukan optimasi pada hal-hal yang berbeda dibandingkan di Mainnet Ethereum. Anda juga akan belajar cara mengimplementasikan optimasi ini.
Pengungkapan penuh
Saya adalah karyawan purnawaktu Optimism (opens in a new tab), jadi contoh-contoh dalam artikel ini akan dijalankan di Optimism. Namun, teknik yang dijelaskan di sini seharusnya berfungsi sama baiknya untuk rollup lainnya.
Terminologi
Saat membahas rollup, istilah 'layer 1' (L1) digunakan untuk Mainnet, jaringan produksi Ethereum. Istilah 'layer 2' (L2) digunakan untuk rollup atau sistem lain apa pun yang bergantung pada L1 untuk keamanan tetapi melakukan sebagian besar pemrosesannya secara offchain.
Bagaimana kita dapat lebih mengurangi biaya transaksi L2?
Optimistic rollup harus menyimpan catatan setiap riwayat transaksi sehingga siapa pun dapat memeriksanya dan memverifikasi bahwa status saat ini sudah benar. Cara termurah untuk memasukkan data ke Mainnet Ethereum adalah dengan menulisnya sebagai calldata. Solusi ini dipilih oleh Optimism (opens in a new tab) maupun Arbitrum (opens in a new tab).
Biaya transaksi L2
Biaya transaksi L2 terdiri dari dua komponen:
- Pemrosesan L2, yang biasanya sangat murah
- Penyimpanan L1, yang terikat dengan biaya gas Mainnet
Saat saya menulis ini, di Optimism biaya gas L2 adalah 0,001 Gwei. Di sisi lain, biaya gas L1 adalah sekitar 40 gwei. Anda dapat melihat harga saat ini di sini (opens in a new tab).
Satu bita calldata memakan biaya 4 gas (jika bernilai nol) atau 16 gas (jika bernilai lainnya). Salah satu operasi paling mahal di Mesin Virtual Ethereum adalah menulis ke penyimpanan. Biaya maksimum untuk menulis kata 32-bita ke penyimpanan di L2 adalah 22.100 gas. Saat ini, nilainya adalah 22,1 gwei. Jadi, jika kita dapat menghemat satu bita nol calldata, kita akan dapat menulis sekitar 200 bita ke penyimpanan dan tetap lebih untung.
ABI
Sebagian besar transaksi mengakses kontrak dari akun yang dimiliki secara eksternal. Sebagian besar kontrak ditulis dalam Solidity dan menafsirkan bidang datanya sesuai antarmuka biner aplikasi (ABI) (opens in a new tab).
Namun, ABI dirancang untuk L1, di mana satu bita calldata memakan biaya yang kira-kira sama dengan empat operasi aritmatika, bukan L2 di mana satu bita calldata memakan biaya lebih dari seribu operasi aritmatika. Calldata dibagi seperti ini:
| Bagian | Panjang | Bita | Bita terbuang | Gas terbuang | Bita yang diperlukan | Gas yang diperlukan |
|---|---|---|---|---|---|---|
| Pemilih fungsi | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Nol | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Alamat tujuan | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Jumlah | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Total | 68 | 160 | 576 |
Penjelasan:
- Pemilih fungsi: Kontrak memiliki kurang dari 256 fungsi, jadi kita dapat membedakannya dengan satu bita. Bita-bita ini biasanya bukan nol dan oleh karena itu memakan biaya enam belas gas (opens in a new tab).
- Nol: Bita-bita ini selalu nol karena alamat dua puluh bita tidak memerlukan kata tiga puluh dua bita untuk menyimpannya.
Bita yang menyimpan nol memakan biaya empat gas (lihat yellow paper (opens in a new tab), Lampiran G,
hal. 27, nilai untuk
Gtxdatazero). - Jumlah: Jika kita berasumsi bahwa dalam kontrak ini
decimalsadalah delapan belas (nilai normal) dan jumlah maksimum token yang kita transfer adalah 1018, kita mendapatkan jumlah maksimum 1036. 25615 > 1036, jadi lima belas bita sudah cukup.
Pemborosan 160 gas di L1 biasanya dapat diabaikan. Sebuah transaksi memakan biaya setidaknya 21.000 gas (opens in a new tab), jadi tambahan 0,8% tidak menjadi masalah.
Namun, di L2, situasinya berbeda. Hampir seluruh biaya transaksi adalah untuk menulisnya ke L1.
Selain calldata transaksi, terdapat 109 bita header transaksi (alamat tujuan, tanda tangan digital, dll.).
Oleh karena itu, total biayanya adalah 109*16+576+160=2480, dan kita membuang sekitar 6,5% dari jumlah tersebut.
Mengurangi biaya saat Anda tidak mengontrol tujuan
Dengan asumsi bahwa Anda tidak memiliki kendali atas kontrak tujuan, Anda masih dapat menggunakan solusi yang mirip dengan yang satu ini (opens in a new tab). Mari kita bahas berkas-berkas yang relevan.
Token.sol
Ini adalah kontrak tujuan (opens in a new tab).
Ini adalah kontrak ERC-20 standar, dengan satu fitur tambahan.
Fungsi faucet ini memungkinkan pengguna mana pun untuk mendapatkan beberapa token untuk digunakan.
Ini akan membuat kontrak ERC-20 produksi menjadi tidak berguna, tetapi ini membuat segalanya lebih mudah ketika ERC-20 hanya ada untuk memfasilitasi pengujian.
1 /**2 * @dev Memberikan pemanggil 1000 token untuk dimainkan3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucet // fungsi faucetCalldataInterpreter.sol
Ini adalah kontrak yang seharusnya dipanggil oleh transaksi dengan calldata yang lebih pendek (opens in a new tab). Mari kita bahas baris demi baris.
1//SPDX-License-Identifier: Unlicense // SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Kita memerlukan fungsi token untuk mengetahui cara memanggilnya.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Alamat token di mana kita bertindak sebagai proksi.
12 /**3 * @dev Menentukan alamat token4 * @param tokenAddr_ alamat kontrak ERC-205 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructor // konstruktorTampilkan semuaAlamat token adalah satu-satunya parameter yang perlu kita tentukan.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {Membaca nilai dari calldata.
1 uint _retVal;23 require(length < 0x21,4 "calldataVal length limit is 32 bytes");56 require(length + startByte <= msg.data.length,7 "calldataVal trying to read beyond calldatasize");Kita akan memuat satu kata 32-bita (256-bit) ke memori dan menghapus bita yang bukan bagian dari bidang yang kita inginkan. Algoritma ini tidak berfungsi untuk nilai yang lebih panjang dari 32 bita, dan tentu saja kita tidak dapat membaca melewati akhir calldata. Di L1 mungkin perlu untuk melewati pengujian ini guna menghemat gas, tetapi di L2 gas sangat murah, yang memungkinkan pemeriksaan kewarasan apa pun yang dapat kita pikirkan.
1 assembly {2 _retVal := calldataload(startByte)3 }Kita bisa saja menyalin data dari panggilan ke fallback() (lihat di bawah), tetapi lebih mudah menggunakan Yul (opens in a new tab), bahasa rakitan dari EVM.
Di sini kita menggunakan opcode CALLDATALOAD (opens in a new tab) untuk membaca bita startByte hingga startByte+31 ke dalam tumpukan.
Secara umum, sintaks opcode di Yul adalah <nama opcode>(<nilai tumpukan pertama, jika ada>,<nilai tumpukan kedua, jika ada>...).
12 _retVal = _retVal >> (256-length*8);Hanya bita length paling signifikan yang merupakan bagian dari bidang tersebut, jadi kita melakukan geser kanan (opens in a new tab) untuk menyingkirkan nilai lainnya.
Ini memiliki keuntungan tambahan yaitu memindahkan nilai ke sebelah kanan bidang, sehingga menjadi nilai itu sendiri alih-alih nilai dikali 256sesuatu.
12 return _retVal;3 }456 fallback() external {Ketika panggilan ke kontrak Solidity tidak cocok dengan tanda tangan fungsi mana pun, ia memanggil fungsi fallback() (opens in a new tab) (dengan asumsi fungsi tersebut ada).
Dalam kasus CalldataInterpreter, semua panggilan masuk ke sini karena tidak ada fungsi external atau public lainnya.
1 uint _func;23 _func = calldataVal(0, 1);Membaca bita pertama dari calldata, yang memberi tahu kita fungsinya. Ada dua alasan mengapa sebuah fungsi tidak akan tersedia di sini:
- Fungsi yang bersifat
pureatauviewtidak mengubah status dan tidak memakan biaya gas (saat dipanggil secara offchain). Tidak masuk akal untuk mencoba mengurangi biaya gasnya. - Fungsi yang bergantung pada
msg.sender(opens in a new tab). Nilaimsg.senderakan menjadi alamatCalldataInterpreter, bukan pemanggilnya.
Sayangnya, melihat spesifikasi ERC-20 (opens in a new tab), ini hanya menyisakan satu fungsi, yaitu transfer.
Ini membuat kita hanya memiliki dua fungsi: transfer (karena kita dapat memanggil transferFrom) dan faucet (karena kita dapat mentransfer token kembali ke siapa pun yang memanggil kita).
12 // Call the state changing methods of token using // Memanggil metode pengubah status dari token menggunakan3 // information from the calldata // informasi dari calldata45 // faucet // faucet6 if (_func == 1) {Panggilan ke faucet(), yang tidak memiliki parameter.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }Setelah kita memanggil token.faucet(), kita mendapatkan token. Namun, sebagai kontrak proksi, kita tidak membutuhkan token.
Akun yang dimiliki secara eksternal atau kontrak yang memanggil kitalah yang membutuhkannya.
Jadi kita mentransfer semua token kita kepada siapa pun yang memanggil kita.
1 // transfer (assume we have an allowance for it) // transfer (asumsikan kita memiliki allowance untuk itu)2 if (_func == 2) {Mentransfer token memerlukan dua parameter: alamat tujuan dan jumlahnya.
1 token.transferFrom(2 msg.sender,Kita hanya mengizinkan pemanggil untuk mentransfer token yang mereka miliki
1 address(uint160(calldataVal(1, 20))),Alamat tujuan dimulai pada bita #1 (bita #0 adalah fungsi). Sebagai sebuah alamat, panjangnya adalah 20 bita.
1 calldataVal(21, 2)Untuk kontrak khusus ini, kita berasumsi bahwa jumlah maksimum token yang ingin ditransfer oleh siapa pun muat dalam dua bita (kurang dari 65536).
1 );2 }Secara keseluruhan, sebuah transfer membutuhkan 35 bita calldata:
| Bagian | Panjang | Bita |
|---|---|---|
| Pemilih fungsi | 1 | 0 |
| Alamat tujuan | 32 | 1-32 |
| Jumlah | 2 | 33-34 |
1 } // fallback // fallback23} // contract CalldataInterpreter // kontrak CalldataInterpretertest.js
Pengujian unit JavaScript ini (opens in a new tab) menunjukkan kepada kita cara menggunakan mekanisme ini (dan cara memverifikasi bahwa ini berfungsi dengan benar). Saya akan berasumsi bahwa Anda memahami chai (opens in a new tab) dan ethers (opens in a new tab) dan hanya menjelaskan bagian-bagian yang secara khusus berlaku untuk kontrak tersebut.
1const { expect } = require("chai");23describe("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)910 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)1415 const signer = await ethers.getSigner()Tampilkan semuaKita mulai dengan menerapkan kedua kontrak.
1 // Get tokens to play with // Dapatkan token untuk dimainkan2 const faucetTx = {Kita tidak dapat menggunakan fungsi tingkat tinggi yang biasanya kita gunakan (seperti token.faucet()) untuk membuat transaksi, karena kita tidak mengikuti ABI.
Sebaliknya, kita harus membangun transaksi itu sendiri dan kemudian mengirimkannya.
1 to: cdi.address,2 data: "0x01"Ada dua parameter yang perlu kita sediakan untuk transaksi:
to, alamat tujuan. Ini adalah kontrak penerjemah calldata.data, calldata yang akan dikirim. Dalam kasus panggilan faucet, datanya adalah satu bita,0x01.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()Kita memanggil metode sendTransaction dari penandatangan (opens in a new tab) karena kita sudah menentukan tujuan (faucetTx.to) dan kita memerlukan transaksi tersebut untuk ditandatangani.
1// Check the faucet provides the tokens correctly // Periksa apakah faucet memberikan token dengan benar2expect(await token.balanceOf(signer.address)).to.equal(1000)Di sini kita memverifikasi saldo.
Tidak perlu menghemat gas pada fungsi view, jadi kita menjalankannya secara normal.
1// Give the CDI an allowance (approvals cannot be proxied) // Beri CDI allowance (persetujuan tidak dapat diproksikan)2const approveTX = await token.approve(cdi.address, 10000)3await approveTX.wait()4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)Memberikan jatah kepada penerjemah calldata agar dapat melakukan transfer.
1// Transfer tokens // Transfer token2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}Membuat transaksi transfer. Bita pertama adalah "0x02", diikuti oleh alamat tujuan, dan terakhir jumlahnya (0x0100, yang berarti 256 dalam desimal).
1 await (await signer.sendTransaction(transferTx)).wait()23 // Check that we have 256 tokens less // Periksa bahwa kita memiliki 256 token lebih sedikit4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them // Dan tujuan kita mendapatkannya7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it // it9}) // describe // describeTampilkan semuaMengurangi biaya saat Anda mengontrol kontrak tujuan
Jika Anda memiliki kendali atas kontrak tujuan, Anda dapat membuat fungsi yang melewati pemeriksaan msg.sender karena mereka memercayai penerjemah calldata.
Anda dapat melihat contoh cara kerjanya di sini, di cabang control-contract (opens in a new tab).
Jika kontrak hanya merespons transaksi eksternal, kita bisa saja hanya memiliki satu kontrak. Namun, hal itu akan merusak komposabilitas. Jauh lebih baik memiliki kontrak yang merespons panggilan ERC-20 normal, dan kontrak lain yang merespons transaksi dengan data panggilan pendek.
Token.sol
Dalam contoh ini kita dapat memodifikasi Token.sol.
Ini memungkinkan kita memiliki sejumlah fungsi yang hanya boleh dipanggil oleh proksi.
Berikut adalah bagian-bagian barunya:
1 // The only address allowed to specify the CalldataInterpreter address // Satu-satunya alamat yang diizinkan untuk menentukan alamat CalldataInterpreter2 address owner;34 // The CalldataInterpreter address // Alamat CalldataInterpreter5 address proxy = address(0);Kontrak ERC-20 perlu mengetahui identitas proksi yang diotorisasi. Namun, kita tidak dapat mengatur variabel ini di konstruktor, karena kita belum mengetahui nilainya. Kontrak ini diinstansiasi terlebih dahulu karena proksi mengharapkan alamat token di konstruktornya.
1 /**2 * @dev Memanggil konstruktor ERC20.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }Alamat pembuat (disebut owner) disimpan di sini karena itu adalah satu-satunya alamat yang diizinkan untuk mengatur proksi.
1 /**2 * @dev mengatur alamat untuk proksi (CalldataInterpreter).3 * Hanya dapat dipanggil sekali oleh pemilik4 */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");89 proxy = _proxy;10 } // function setProxy // fungsi setProxyTampilkan semuaProksi memiliki akses istimewa, karena dapat melewati pemeriksaan keamanan.
Untuk memastikan kita dapat memercayai proksi, kita hanya membiarkan owner memanggil fungsi ini, dan hanya sekali.
Setelah proxy memiliki nilai nyata (bukan nol), nilai tersebut tidak dapat berubah, jadi meskipun pemilik memutuskan untuk berbuat jahat, atau mnemonik untuknya terungkap, kita tetap aman.
1 /**2 * @dev Beberapa fungsi mungkin hanya dapat dipanggil oleh proksi.3 */4 modifier onlyProxy {Ini adalah fungsi modifier (opens in a new tab), yang memodifikasi cara kerja fungsi lainnya.
1 require(msg.sender == proxy);Pertama, verifikasi bahwa kita dipanggil oleh proksi dan bukan oleh orang lain.
Jika tidak, revert.
1 _;2 }Jika ya, jalankan fungsi yang kita modifikasi.
1 /* Fungsi yang memungkinkan proksi untuk benar-benar menjadi proksi bagi akun */2 /* Functions that allow the proxy to actually proxy for accounts */34 function transferProxy(address from, address to, uint256 amount)5 public virtual onlyProxy() returns (bool)6 {7 _transfer(from, to, amount);8 return true;9 }1011 function approveProxy(address from, address spender, uint256 amount)12 public virtual onlyProxy() returns (bool)13 {14 _approve(from, spender, amount);15 return true;16 }1718 function transferFromProxy(19 address spender,20 address from,21 address to,22 uint256 amount23 ) public virtual onlyProxy() returns (bool)24 {25 _spendAllowance(from, spender, amount);26 _transfer(from, to, amount);27 return true;28 }Tampilkan semuaIni adalah tiga operasi yang biasanya mengharuskan pesan datang langsung dari entitas yang mentransfer token atau menyetujui jatah. Di sini kita memiliki versi proksi dari operasi-operasi ini yang:
- Dimodifikasi oleh
onlyProxy()sehingga tidak ada orang lain yang diizinkan untuk mengendalikannya. - Mendapatkan alamat yang biasanya berupa
msg.sendersebagai parameter tambahan.
CalldataInterpreter.sol
Penerjemah calldata hampir identik dengan yang di atas, kecuali bahwa fungsi yang diproksikan menerima parameter msg.sender dan tidak diperlukan jatah untuk transfer.
1 // transfer (no need for allowance) // transfer (tidak perlu allowance)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // approve // approve11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom // transferFrom20 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 }Tampilkan semuaTest.js
Ada beberapa perubahan antara kode pengujian sebelumnya dan yang ini.
1const Cdi = await ethers.getContractFactory("CalldataInterpreter")2const cdi = await Cdi.deploy(token.address)3await cdi.deployed()4await token.setProxy(cdi.address)Kita perlu memberi tahu kontrak ERC-20 proksi mana yang harus dipercaya
1console.log("CalldataInterpreter addr:", cdi.address)23// Need two signers to verify allowances // Butuh dua penandatangan untuk memverifikasi allowance4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]Untuk memeriksa approve() dan transferFrom(), kita memerlukan penandatangan kedua.
Kita menyebutnya poorSigner karena ia tidak mendapatkan token kita (tentu saja ia harus memiliki ETH).
1// Transfer tokens // Transfer token2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()Karena kontrak ERC-20 memercayai proksi (cdi), kita tidak memerlukan jatah untuk meneruskan transfer.
1// approval and transferFrom // approval dan transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const 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()1516// Check the approve / transferFrom combo was done correctly // Periksa apakah kombo approve / transferFrom dilakukan dengan benar17expect(await token.balanceOf(destAddr2)).to.equal(255)Tampilkan semuaMenguji dua fungsi baru.
Perhatikan bahwa transferFromTx memerlukan dua parameter alamat: pemberi jatah dan penerima.
Kesimpulan
Optimism (opens in a new tab) maupun Arbitrum (opens in a new tab) sedang mencari cara untuk mengurangi ukuran calldata yang ditulis ke L1 dan dengan demikian mengurangi biaya transaksi. Namun, sebagai penyedia infrastruktur yang mencari solusi generik, kemampuan kami terbatas. Sebagai pengembang dapp, Anda memiliki pengetahuan khusus aplikasi, yang memungkinkan Anda mengoptimalkan calldata Anda jauh lebih baik daripada yang bisa kami lakukan dalam solusi generik. Semoga artikel ini membantu Anda menemukan solusi ideal untuk kebutuhan Anda.
Lihat di sini untuk karya saya yang lain (opens in a new tab).
Pembaruan terakhir halaman: 22 Agustus 2025