Zum Hauptinhalt springen

Kurze ABIs zur Calldata-Optimierung

Ebene 2
Fortgeschritten
Ori Pomerantz
1. April 2022
14 Minuten Lesezeit

Einführung

In diesem Artikel erfahren Sie mehr über Optimistic Rollups, die Kosten von Transaktionen auf diesen und wie diese unterschiedliche Kostenstruktur es erfordert, für andere Dinge zu optimieren als im Ethereum-Mainnet. Sie lernen auch, wie Sie diese Optimierung implementieren.

Vollständige Offenlegung

Ich bin ein Vollzeitmitarbeiter bei Optimism (opens in a new tab), daher werden die Beispiele in diesem Artikel auf Optimism ausgeführt. Die hier erklärte Technik sollte jedoch genauso gut für andere Rollups funktionieren.

Terminologie

Wenn über Rollups diskutiert wird, wird der Begriff 'Layer 1' (L1) für das Mainnet, das produktive Ethereum-Netzwerk, verwendet. Der Begriff 'Ebene 2' (L2) wird für das Rollup oder jedes andere System verwendet, das sich für die Sicherheit auf L1 verlässt, aber den Großteil seiner Verarbeitung Off-Chain durchführt.

Wie können wir die Kosten für L2-Transaktionen weiter senken?

Optimistic Rollups müssen eine Aufzeichnung jeder historischen Transaktion aufbewahren, damit jeder sie durchgehen und überprüfen kann, ob der aktuelle Zustand korrekt ist. Der günstigste Weg, Daten in das Ethereum-Mainnet zu bekommen, ist, sie als Calldata zu schreiben. Diese Lösung wurde sowohl von Optimism (opens in a new tab) als auch von Arbitrum (opens in a new tab) gewählt.

Kosten von L2-Transaktionen

Die Kosten von L2-Transaktionen setzen sich aus zwei Komponenten zusammen:

  1. L2-Verarbeitung, die in der Regel extrem günstig ist
  2. L1-Speicherung, die an die Gaskosten des Mainnets gebunden ist

Während ich dies schreibe, betragen die Kosten für L2-Gas auf Optimism 0,001 Gwei. Die Kosten für L1-Gas liegen hingegen bei etwa 40 Gwei. Sie können die aktuellen Preise hier einsehen (opens in a new tab).

Ein Byte Calldata kostet entweder 4 Gas (wenn es null ist) oder 16 Gas (wenn es ein anderer Wert ist). Eine der teuersten Operationen auf der EVM ist das Schreiben in den Speicher. Die maximalen Kosten für das Schreiben eines 32-Byte-Wortes in den Speicher auf L2 betragen 22.100 Gas. Derzeit sind das 22,1 Gwei. Wenn wir also ein einziges Null-Byte an Calldata einsparen können, können wir etwa 200 Bytes in den Speicher schreiben und haben immer noch einen Vorteil.

Die ABI

Die überwiegende Mehrheit der Transaktionen greift von einem extern verwalteten Konto auf einen Vertrag zu. Die meisten Verträge sind in Solidity geschrieben und interpretieren ihr Datenfeld gemäß der Application Binary Interface (ABI) (opens in a new tab).

Die ABI wurde jedoch für L1 entwickelt, wo ein Byte Calldata ungefähr so viel kostet wie vier arithmetische Operationen, und nicht für L2, wo ein Byte Calldata mehr als tausend arithmetische Operationen kostet. Die Calldata ist wie folgt aufgeteilt:

AbschnittLängeBytesVerschwendete BytesVerschwendetes GasNotwendige BytesNotwendiges Gas
Funktionsselektor40-3348116
Nullen124-15124800
Zieladresse2016-350020320
Betrag3236-67176415240
Gesamt68160576

Erklärung:

  • Funktionsselektor: Der Vertrag hat weniger als 256 Funktionen, sodass wir sie mit einem einzigen Byte unterscheiden können. Diese Bytes sind typischerweise ungleich null und kosten daher sechzehn Gas (opens in a new tab).
  • Nullen: Diese Bytes sind immer null, da eine 20-Byte-Adresse kein 32-Byte-Wort benötigt, um sie zu speichern. Bytes, die null enthalten, kosten vier Gas (siehe das Yellow Paper (opens in a new tab), Anhang G, S. 27, der Wert für Gtxdatazero).
  • Betrag: Wenn wir annehmen, dass in diesem Vertrag decimals achtzehn ist (der normale Wert) und der maximale Betrag an Token, den wir übertragen, 1018 beträgt, erhalten wir einen maximalen Betrag von 1036. 25615 > 1036, also reichen fünfzehn Bytes aus.

Eine Verschwendung von 160 Gas auf L1 ist normalerweise vernachlässigbar. Eine Transaktion kostet mindestens 21.000 Gas (opens in a new tab), also spielen zusätzliche 0,8 % keine Rolle. Auf L2 sieht die Sache jedoch anders aus. Fast die gesamten Kosten der Transaktion entstehen durch das Schreiben auf L1. Zusätzlich zu den Transaktions-Calldata gibt es 109 Bytes an Transaktions-Header (Zieladresse, Signatur usw.). Die Gesamtkosten betragen daher 109*16+576+160=2480, und wir verschwenden etwa 6,5 % davon.

Kosten senken, wenn Sie das Ziel nicht kontrollieren

Unter der Annahme, dass Sie keine Kontrolle über den Zielvertrag haben, können Sie dennoch eine Lösung verwenden, die dieser hier (opens in a new tab) ähnlich ist. Lassen Sie uns die relevanten Dateien durchgehen.

Token.sol

Dies ist der Zielvertrag (opens in a new tab). Es handelt sich um einen Standard-ERC-20-Vertrag mit einer zusätzlichen Funktion. Diese faucet-Funktion ermöglicht es jedem Benutzer, einige Token zur Verwendung zu erhalten. Sie würde einen produktiven ERC-20-Vertrag nutzlos machen, aber sie erleichtert das Leben, wenn ein ERC-20 nur zu Testzwecken existiert.

1 /* *
2 * @dev Gibt dem Aufrufer 1000 Token zum Spielen */
3
4
5
6 function faucet() external {
7 _mint(msg.sender, 1000);
8 } // function faucet

CalldataInterpreter.sol

Dies ist der Vertrag, den Transaktionen mit kürzeren Calldata aufrufen sollen (opens in a new tab). Lassen Sie uns ihn Zeile für Zeile durchgehen.

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

Wir benötigen die Token-Funktion, um zu wissen, wie wir sie aufrufen können.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

Die Adresse des Tokens, für den wir ein Proxy sind.

1
2 /* *
3 * @dev Gibt die Token-Adresse an
4 * @param tokenAddr_ ERC-20-Vertragsadresse */
5
6
7
8
9 constructor(
10 address tokenAddr_
11 ) {
12 token = OrisUselessToken(tokenAddr_);
13 } // constructor

Die Token-Adresse ist der einzige Parameter, den wir angeben müssen.

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

Einen Wert aus den Calldata lesen.

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");

Wir werden ein einzelnes 32-Byte-Wort (256-Bit) in den Speicher laden und die Bytes entfernen, die nicht Teil des gewünschten Feldes sind. Dieser Algorithmus funktioniert nicht für Werte, die länger als 32 Bytes sind, und natürlich können wir nicht über das Ende der Calldata hinaus lesen. Auf L1 könnte es notwendig sein, diese Tests zu überspringen, um Gas zu sparen, aber auf L2 ist Gas extrem günstig, was alle möglichen Plausibilitätsprüfungen ermöglicht, die wir uns vorstellen können.

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

Wir hätten die Daten aus dem Aufruf von fallback() (siehe unten) kopieren können, aber es ist einfacher, Yul (opens in a new tab), die Assemblersprache der EVM, zu verwenden.

Hier verwenden wir den CALLDATALOAD-Opcode (opens in a new tab), um die Bytes startByte bis startByte+31 in den Stack zu lesen. Im Allgemeinen lautet die Syntax eines Opcodes in Yul <opcode name>(<first stack value, if any>,<second stack value, if any>...).

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

Nur die höchstwertigen length-Bytes sind Teil des Feldes, also führen wir eine Rechtsverschiebung (Right-Shift) (opens in a new tab) durch, um die anderen Werte loszuwerden. Dies hat den zusätzlichen Vorteil, dass der Wert an den rechten Rand des Feldes verschoben wird, sodass es der Wert selbst ist und nicht der Wert mal 256irgendwas.

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

Wenn ein Aufruf an einen Solidity-Vertrag mit keiner der Funktionssignaturen übereinstimmt, ruft er die fallback()-Funktion (opens in a new tab) auf (vorausgesetzt, es gibt eine). Im Fall von CalldataInterpreter landet jeder Aufruf hier, da es keine anderen external- oder public-Funktionen gibt.

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

Lesen Sie das erste Byte der Calldata, das uns die Funktion mitteilt. Es gibt zwei Gründe, warum eine Funktion hier nicht verfügbar sein könnte:

  1. Funktionen, die pure oder view sind, ändern den Zustand nicht und kosten kein Gas (wenn sie Off-Chain aufgerufen werden). Es macht keinen Sinn zu versuchen, ihre Gaskosten zu senken.
  2. Funktionen, die sich auf msg.sender (opens in a new tab) verlassen. Der Wert von msg.sender wird die Adresse von CalldataInterpreter sein, nicht die des Aufrufers.

Leider bleibt bei Betrachtung der ERC-20-Spezifikationen (opens in a new tab) nur eine Funktion übrig: transfer. Damit bleiben uns nur zwei Funktionen: transfer (weil wir transferFrom aufrufen können) und faucet (weil wir die Token an denjenigen zurückübertragen können, der uns aufgerufen hat).

1
2 // Ruft die zustandsändernden Methoden des Tokens auf unter Verwendung von
3 // Informationen aus den Calldata
4
5 // faucet
6 if (_func == 1) {

Ein Aufruf von faucet(), der keine Parameter hat.

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

Nachdem wir token.faucet() aufgerufen haben, erhalten wir Token. Allerdings brauchen wir als Proxy-Vertrag keine Token. Das EOA (Extern verwaltetes Konto) oder der Vertrag, der uns aufgerufen hat, hingegen schon. Also übertragen wir alle unsere Token an denjenigen, der uns aufgerufen hat.

1 // transfer (angenommen, wir haben eine Allowance dafür)
2 if (_func == 2) {

Die Übertragung von Token erfordert zwei Parameter: die Zieladresse und den Betrag.

1 token.transferFrom(
2 msg.sender,

Wir erlauben Aufrufern nur, Token zu übertragen, die sie besitzen

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

Die Zieladresse beginnt bei Byte #1 (Byte #0 ist die Funktion). Als Adresse ist sie 20 Bytes lang.

1 calldataVal(21, 2)

Für diesen speziellen Vertrag nehmen wir an, dass die maximale Anzahl an Token, die jemand übertragen möchte, in zwei Bytes passt (weniger als 65536).

1 );
2 }

Insgesamt benötigt eine Übertragung 35 Bytes an Calldata:

AbschnittLängeBytes
Funktionsselektor10
Zieladresse321-32
Betrag233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.js

Dieser JavaScript-Unit-Test (opens in a new tab) zeigt uns, wie wir diesen Mechanismus verwenden (und wie wir überprüfen, ob er korrekt funktioniert). Ich gehe davon aus, dass Sie chai (opens in a new tab) und ethers (opens in a new tab) verstehen, und erkläre nur die Teile, die sich speziell auf den Vertrag beziehen.

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()

Wir beginnen mit der Bereitstellung beider Verträge.

1 // Token zum Spielen erhalten
2 const faucetTx = {

Wir können nicht die High-Level-Funktionen verwenden, die wir normalerweise nutzen würden (wie token.faucet()), um Transaktionen zu erstellen, da wir der ABI nicht folgen. Stattdessen müssen wir die Transaktion selbst erstellen und dann senden.

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

Es gibt zwei Parameter, die wir für die Transaktion angeben müssen:

  1. to, die Zieladresse. Dies ist der Calldata-Interpreter-Vertrag.
  2. data, die zu sendenden Calldata. Im Falle eines Faucet-Aufrufs bestehen die Daten aus einem einzigen Byte, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

Wir rufen die sendTransaction-Methode des Signers (opens in a new tab) auf, da wir das Ziel bereits angegeben haben (faucetTx.to) und die Transaktion signiert werden muss.

1// Überprüfen, ob das Faucet die Token korrekt bereitstellt
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Hier überprüfen wir das Guthaben. Es besteht keine Notwendigkeit, bei view-Funktionen Gas zu sparen, also führen wir sie einfach normal aus.

1// Dem CDI eine Allowance erteilen (Approvals können nicht über einen Proxy weitergeleitet werden)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Geben Sie dem Calldata-Interpreter eine Freigabe (Allowance), um Übertragungen durchführen zu können.

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

Erstellen Sie eine Übertragungstransaktion. Das erste Byte ist „0x02“, gefolgt von der Zieladresse und schließlich dem Betrag (0x0100, was dezimal 256 entspricht).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Überprüfen, ob wir 256 Token weniger haben
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // Und dass unser Ziel sie erhalten hat
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe

Kosten senken, wenn Sie den Zielvertrag kontrollieren

Wenn Sie die Kontrolle über den Zielvertrag haben, können Sie Funktionen erstellen, die die msg.sender-Prüfungen umgehen, da sie dem Calldata-Interpreter vertrauen. Ein Beispiel dafür, wie das funktioniert, finden Sie hier im control-contract-Branch (opens in a new tab).

Wenn der Vertrag nur auf externe Transaktionen reagieren würde, kämen wir mit nur einem Vertrag aus. Das würde jedoch die Zusammensetzbarkeit (Composability) beeinträchtigen. Es ist viel besser, einen Vertrag zu haben, der auf normale ERC-20-Aufrufe reagiert, und einen weiteren Vertrag, der auf Transaktionen mit kurzen Calldata reagiert.

Token.sol

In diesem Beispiel können wir Token.sol ändern. Dadurch können wir eine Reihe von Funktionen haben, die nur der Proxy aufrufen darf. Hier sind die neuen Teile:

1 // Die einzige Adresse, die die CalldataInterpreter-Adresse angeben darf
2 address owner;
3
4 // Die CalldataInterpreter-Adresse
5 address proxy = address(0);

Der ERC-20-Vertrag muss die Identität des autorisierten Proxys kennen. Wir können diese Variable jedoch nicht im Konstruktor festlegen, da wir den Wert noch nicht kennen. Dieser Vertrag wird zuerst instanziiert, da der Proxy die Adresse des Tokens in seinem Konstruktor erwartet.

1 /* *
2 * @dev Ruft den ERC20-Konstruktor auf. */
3
4
5
6 constructor(
7 ) ERC20("Oris useless token-2", "OUT-2") {
8 owner = msg.sender;
9 }

Die Adresse des Erstellers (genannt owner) wird hier gespeichert, da dies die einzige Adresse ist, die den Proxy festlegen darf.

1 /* *
2 * @dev Setzt die Adresse für den Proxy (den CalldataInterpreter).
3 * Kann nur einmal vom Eigentümer aufgerufen werden */
4
5
6
7
8 function setProxy(address _proxy) external {
9 require(msg.sender == owner, "Can only be called by owner");
10 require(proxy == address(0), "Proxy is already set");
11
12 proxy = _proxy;
13 } // function setProxy

Der Proxy hat privilegierten Zugriff, da er Sicherheitsprüfungen umgehen kann. Um sicherzustellen, dass wir dem Proxy vertrauen können, lassen wir nur den owner diese Funktion aufrufen, und das nur einmal. Sobald proxy einen echten Wert hat (nicht null), kann sich dieser Wert nicht mehr ändern. Selbst wenn der Eigentümer beschließt, bösartig zu werden, oder die Mnemonic dafür aufgedeckt wird, sind wir immer noch sicher.

1 /* *
2 * @dev Einige Funktionen dürfen nur vom Proxy aufgerufen werden. */
3
4
5
6 modifier onlyProxy {

Dies ist eine modifier-Funktion (opens in a new tab), sie ändert die Art und Weise, wie andere Funktionen arbeiten.

1 require(msg.sender == proxy);

Überprüfen Sie zunächst, ob wir vom Proxy und von niemand anderem aufgerufen wurden. Wenn nicht, führen Sie einen revert durch.

1 _;
2 }

Wenn ja, führen Sie die Funktion aus, die wir modifizieren.

1 /* Funktionen, die es dem Proxy ermöglichen, tatsächlich als Proxy für Konten zu fungieren */
2 /* Functions that allow the proxy to actually proxy for accounts */
3
4 function transferProxy(address from, address to, uint256 amount)
5 public virtual onlyProxy() returns (bool)
6 {
7 _transfer(from, to, amount);
8 return true;
9 }
10
11 function approveProxy(address from, address spender, uint256 amount)
12 public virtual onlyProxy() returns (bool)
13 {
14 _approve(from, spender, amount);
15 return true;
16 }
17
18 function transferFromProxy(
19 address spender,
20 address from,
21 address to,
22 uint256 amount
23 ) public virtual onlyProxy() returns (bool)
24 {
25 _spendAllowance(from, spender, amount);
26 _transfer(from, to, amount);
27 return true;
28 }

Dies sind drei Operationen, die normalerweise erfordern, dass die Nachricht direkt von der Entität stammt, die Token überträgt oder eine Freigabe genehmigt. Hier haben wir eine Proxy-Version dieser Operationen, die:

  1. Durch onlyProxy() modifiziert wird, sodass niemand sonst sie kontrollieren darf.
  2. Die Adresse, die normalerweise msg.sender wäre, als zusätzlichen Parameter erhält.

CalldataInterpreter.sol

Der Calldata-Interpreter ist fast identisch mit dem obigen, mit der Ausnahme, dass die Proxy-Funktionen einen msg.sender-Parameter erhalten und keine Freigabe für transfer erforderlich ist.

1 // transfer (keine Allowance erforderlich)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
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 }

Test.js

Es gibt ein paar Änderungen zwischen dem vorherigen Testcode und diesem.

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

Wir müssen dem ERC-20-Vertrag mitteilen, welchem Proxy er vertrauen soll

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Benötigt zwei Unterzeichner, um Allowances zu verifizieren
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

Um approve() und transferFrom() zu überprüfen, benötigen wir einen zweiten Signer. Wir nennen ihn poorSigner, weil er keine unserer Token erhält (er muss natürlich ETH haben).

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

Da der ERC-20-Vertrag dem Proxy (cdi) vertraut, benötigen wir keine Freigabe, um Übertragungen weiterzuleiten.

1// approval und 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// Überprüfen, ob die Kombination aus approve / transferFrom korrekt ausgeführt wurde
17expect(await token.balanceOf(destAddr2)).to.equal(255)

Testen Sie die beiden neuen Funktionen. Beachten Sie, dass transferFromTx zwei Adressparameter erfordert: den Geber der Freigabe und den Empfänger.

Fazit

Sowohl Optimism (opens in a new tab) als auch Arbitrum (opens in a new tab) suchen nach Wegen, die Größe der auf L1 geschriebenen Calldata und damit die Kosten von Transaktionen zu reduzieren. Als Infrastrukturanbieter, die nach generischen Lösungen suchen, sind unsere Möglichkeiten jedoch begrenzt. Als Dapp-Entwickler verfügen Sie über anwendungsspezifisches Wissen, mit dem Sie Ihre Calldata viel besser optimieren können, als wir es in einer generischen Lösung könnten. Hoffentlich hilft Ihnen dieser Artikel dabei, die ideale Lösung für Ihre Bedürfnisse zu finden.

Weitere meiner Arbeiten finden Sie hier (opens in a new tab).

Letzte Aktualisierung der Seite: 3. März 2026

War dieses Tutorial hilfreich?