Zum Hauptinhalt springen

All you can cache

Ebene 2
Caching
Speicher
Skalierung
Fortgeschritten
Ori Pomerantz
15. September 2022
23 Minuten Lesezeit

Wenn man Rollups verwendet, sind die Kosten für ein Byte in der Transaktion viel teurer als die Kosten für einen Speicherplatz. Daher ist es sinnvoll, so viele Informationen wie möglich auf der Blockchain zu cachen.

In diesem Artikel lernen Sie, wie Sie einen Caching-Vertrag so erstellen und verwenden, dass jeder Parameterwert, der wahrscheinlich mehrfach verwendet wird, gecacht wird und (nach dem ersten Mal) mit einer viel geringeren Anzahl von Bytes zur Verfügung steht, und wie Sie Off-Chain-Code schreiben, der diesen Cache nutzt.

Wenn Sie den Artikel überspringen und nur den Quellcode sehen möchten, finden Sie ihn hier (opens in a new tab). Der Entwicklungs-Stack ist Foundry (opens in a new tab).

Gesamtdesign

Der Einfachheit halber gehen wir davon aus, dass alle Transaktionsparameter uint256 und 32 Bytes lang sind. Wenn wir eine Transaktion erhalten, parsen wir jeden Parameter wie folgt:

  1. Wenn das erste Byte 0xFF ist, nehmen Sie die nächsten 32 Bytes als Parameterwert und schreiben Sie ihn in den Cache.

  2. Wenn das erste Byte 0xFE ist, nehmen Sie die nächsten 32 Bytes als Parameterwert, aber schreiben Sie ihn nicht in den Cache.

  3. Für jeden anderen Wert nehmen Sie die oberen vier Bits als Anzahl der zusätzlichen Bytes und die unteren vier Bits als die höchstwertigen Bits des Cache-Schlüssels. Hier sind einige Beispiele:

    Bytes in CalldataCache-Schlüssel
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Cache-Manipulation

Der Cache ist in Cache.sol (opens in a new tab) implementiert. Gehen wir ihn Zeile für Zeile durch.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4
5contract Cache {
6
7 bytes1 public constant INTO_CACHE = 0xFF;
8 bytes1 public constant DONT_CACHE = 0xFE;

Diese Konstanten werden verwendet, um die Sonderfälle zu interpretieren, in denen wir alle Informationen bereitstellen und sie entweder in den Cache schreiben wollen oder nicht. Das Schreiben in den Cache erfordert zwei SSTORE (opens in a new tab)-Operationen in zuvor ungenutzte Speicherplätze zu Kosten von jeweils 22100 Gas, daher machen wir es optional.

1
2 mapping(uint => uint) public val2key;

Ein Mapping (opens in a new tab) zwischen den Werten und ihren Schlüsseln. Diese Information ist notwendig, um Werte zu kodieren, bevor Sie die Transaktion absenden.

1 // Position n hat den Wert für Schlüssel n+1, weil wir
2 // Null als "nicht im Cache" beibehalten müssen.
3 uint[] public key2val;

Wir können ein Array für das Mapping von Schlüsseln zu Werten verwenden, da wir die Schlüssel zuweisen und dies der Einfachheit halber sequenziell tun.

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "Reading uninitialize cache entry");
3 return key2val[_key-1];
4 } // cacheRead

Einen Wert aus dem Cache lesen.

1 // Einen Wert in den Cache schreiben, falls er noch nicht vorhanden ist
2 // Nur public, damit der Test funktioniert
3 function cacheWrite(uint _value) public returns (uint) {
4 // Wenn der Wert bereits im Cache ist, den aktuellen Schlüssel zurückgeben
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

Es hat keinen Sinn, denselben Wert mehr als einmal in den Cache zu legen. Wenn der Wert bereits vorhanden ist, geben Sie einfach den vorhandenen Schlüssel zurück.

1 // Da 0xFE ein Sonderfall ist, ist der größte Schlüssel, den der Cache
2 // halten kann, 0x0D gefolgt von 15 0xFFs. Wenn die Cache-Länge bereits so
3 // groß ist, fehlschlagen.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "cache overflow");

Ich glaube nicht, dass wir jemals einen so großen Cache bekommen werden (etwa 1,8*1037 Einträge, was etwa 1027 TB Speicherplatz erfordern würde). Ich bin jedoch alt genug, um mich an "640kB würden immer ausreichen" (opens in a new tab) zu erinnern. Dieser Test ist sehr günstig.

1 // Den Wert mit dem nächsten Schlüssel schreiben
2 val2key[_value] = key2val.length+1;

Fügen Sie das Reverse-Lookup (vom Wert zum Schlüssel) hinzu.

1 key2val.push(_value);

Fügen Sie das Forward-Lookup (vom Schlüssel zum Wert) hinzu. Da wir Werte sequenziell zuweisen, können wir ihn einfach nach dem letzten Array-Wert hinzufügen.

1 return key2val.length;
2 } // cacheWrite

Geben Sie die neue Länge von key2val zurück, was der Zelle entspricht, in der der neue Wert gespeichert ist.

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

Diese Funktion liest einen Wert beliebiger Länge (bis zu 32 Bytes, die Wortgröße) aus den Calldata.

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

Diese Funktion ist intern. Wenn der Rest des Codes also korrekt geschrieben ist, sind diese Tests nicht erforderlich. Sie kosten jedoch nicht viel, also können wir sie genauso gut einbauen.

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

Dieser Code ist in Yul (opens in a new tab) geschrieben. Er liest einen 32-Byte-Wert aus den Calldata. Dies funktioniert auch dann, wenn die Calldata vor startByte+32 enden, da nicht initialisierter Speicherplatz in der EVM als Null betrachtet wird.

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

Wir wollen nicht unbedingt einen 32-Byte-Wert. Dies entfernt die überschüssigen Bytes.

1 return _retVal;
2 } // _calldataVal
3
4
5 // Einen einzelnen Parameter aus den Calldata lesen, beginnend bei _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

Lesen Sie einen einzelnen Parameter aus den Calldata. Beachten Sie, dass wir nicht nur den gelesenen Wert zurückgeben müssen, sondern auch die Position des nächsten Bytes, da Parameter zwischen 1 Byte und 33 Bytes lang sein können.

1 // Das erste Byte sagt uns, wie der Rest zu interpretieren ist
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidity versucht, die Anzahl der Fehler zu reduzieren, indem es potenziell gefährliche implizite Typkonvertierungen (opens in a new tab) verbietet. Ein Downgrade, zum Beispiel von 256 Bits auf 8 Bits, muss explizit erfolgen.

1
2 // Den Wert lesen, aber nicht in den Cache schreiben
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Den Wert lesen und in den Cache schreiben
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // Wenn wir hier angelangt sind, bedeutet das, dass wir aus dem Cache lesen müssen
14
15 // Anzahl der zusätzlich zu lesenden Bytes
16 uint8 _extraBytes = _firstByte / 16;

Nehmen Sie das untere Nibble (opens in a new tab) (Halbbyte) und kombinieren Sie es mit den anderen Bytes, um den Wert aus dem Cache zu lesen.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // n Parameter lesen (Funktionen wissen, wie viele Parameter sie erwarten)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {

Wir könnten die Anzahl der Parameter, die wir haben, aus den Calldata selbst beziehen, aber die Funktionen, die uns aufrufen, wissen, wie viele Parameter sie erwarten. Es ist einfacher, sie uns das mitteilen zu lassen.

1 // Die Parameter, die wir lesen
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parameter beginnen bei Byte 4, davor steht die Funktionssignatur
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }

Lesen Sie die Parameter, bis Sie die benötigte Anzahl haben. Wenn wir über das Ende der Calldata hinausgehen, wird _readParams den Aufruf rückgängig machen (revert).

1
2 return(params);
3 } // readParams
4
5 // Zum Testen von _readParams, das Lesen von vier Parametern testen
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam

Ein großer Vorteil von Foundry ist, dass es erlaubt, Tests in Solidity zu schreiben (siehe Testen des Caches unten). Das macht Unit-Tests viel einfacher. Dies ist eine Funktion, die vier Parameter liest und sie zurückgibt, damit der Test überprüfen kann, ob sie korrekt waren.

1 // Einen Wert abrufen, Bytes zurückgeben, die ihn kodieren (wenn möglich unter Verwendung des Caches)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal ist eine Funktion, die von Off-Chain-Code aufgerufen wird, um bei der Erstellung von Calldata zu helfen, die den Cache nutzen. Sie empfängt einen einzelnen Wert und gibt die Bytes zurück, die ihn kodieren. Diese Funktion ist eine view-Funktion, erfordert also keine Transaktion und kostet bei externem Aufruf kein Gas.

1 uint _key = val2key[_val];
2
3 // Der Wert ist noch nicht im Cache, ihn hinzufügen
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

In der EVM wird angenommen, dass der gesamte nicht initialisierte Speicher aus Nullen besteht. Wenn wir also nach dem Schlüssel für einen Wert suchen, der nicht vorhanden ist, erhalten wir eine Null. In diesem Fall sind die Bytes, die ihn kodieren, INTO_CACHE (damit er beim nächsten Mal gecacht wird), gefolgt vom eigentlichen Wert.

1 // Wenn der Schlüssel <0x10 ist, ihn als einzelnes Byte zurückgeben
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

Einzelne Bytes sind am einfachsten. Wir verwenden einfach bytes.concat (opens in a new tab), um einen bytes<n>-Typ in ein Byte-Array umzuwandeln, das eine beliebige Länge haben kann. Trotz des Namens funktioniert es einwandfrei, wenn es mit nur einem Argument versehen wird.

1 // Zwei-Byte-Wert, kodiert als 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

Wenn wir einen Schlüssel haben, der kleiner als 163 ist, können wir ihn in zwei Bytes ausdrücken. Wir konvertieren zunächst _key, was ein 256-Bit-Wert ist, in einen 16-Bit-Wert und verwenden ein logisches ODER, um die Anzahl der zusätzlichen Bytes zum ersten Byte hinzuzufügen. Dann wandeln wir ihn einfach in einen bytes2-Wert um, der in bytes konvertiert werden kann.

1 // Es gibt wahrscheinlich einen cleveren Weg, die folgenden Zeilen als Schleife auszuführen,
2 // aber es ist eine View-Funktion, daher optimiere ich auf Programmierzeit und
3 // Einfachheit.
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));

Die anderen Werte (3 Bytes, 4 Bytes usw.) werden auf die gleiche Weise behandelt, nur mit unterschiedlichen Feldgrößen.

1 // Wenn wir hier ankommen, stimmt etwas nicht.
2 revert("Error in encodeVal, should not happen");

Wenn wir hier ankommen, bedeutet das, dass wir einen Schlüssel erhalten haben, der nicht kleiner als 16*25615 ist. Aber cacheWrite begrenzt die Schlüssel, sodass wir nicht einmal bis zu 14*25616 kommen können (was ein erstes Byte von 0xFE hätte, also wie DONT_CACHE aussehen würde). Aber es kostet uns nicht viel, einen Test hinzuzufügen, für den Fall, dass ein zukünftiger Programmierer einen Fehler einbaut.

1 } // encodeVal
2
3} // Cache

Testen des Caches

Einer der Vorteile von Foundry ist, dass es Ihnen ermöglicht, Tests in Solidity zu schreiben (opens in a new tab), was das Schreiben von Unit-Tests erleichtert. Die Tests für die Cache-Klasse finden Sie hier (opens in a new tab). Da sich der Testcode wiederholt, wie es bei Tests üblich ist, erklärt dieser Artikel nur die interessanten Teile.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Muss `forge test -vv` für die Konsole ausführen.
8import "forge-std/console.sol";

Dies ist nur Boilerplate-Code, der notwendig ist, um das Testpaket und console.log zu verwenden.

1import "src/Cache.sol";

Wir müssen den Vertrag kennen, den wir testen.

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

Die setUp-Funktion wird vor jedem Test aufgerufen. In diesem Fall erstellen wir einfach einen neuen Cache, damit sich unsere Tests nicht gegenseitig beeinflussen.

1 function testCaching() public {

Tests sind Funktionen, deren Namen mit test beginnen. Diese Funktion überprüft die grundlegende Cache-Funktionalität, das Schreiben von Werten und das erneute Lesen.

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

So führen Sie das eigentliche Testen durch, indem Sie assert...-Funktionen (opens in a new tab) verwenden. In diesem Fall überprüfen wir, ob der Wert, den wir geschrieben haben, derjenige ist, den wir lesen. Wir können das Ergebnis von cache.cacheWrite verwerfen, da wir wissen, dass Cache-Schlüssel linear zugewiesen werden.

1 }
2 } // testCaching
3
4
5 // Denselben Wert mehrmals zwischenspeichern, sicherstellen, dass der Schlüssel
6 // gleich bleibt
7 function testRepeatCaching() public {
8 for(uint i=1; i<100; i++) {
9 uint _key1 = cache.cacheWrite(i);
10 uint _key2 = cache.cacheWrite(i);
11 assertEq(_key1, _key2);
12 }

Zuerst schreiben wir jeden Wert zweimal in den Cache und stellen sicher, dass die Schlüssel gleich sind (was bedeutet, dass das zweite Schreiben nicht wirklich stattgefunden hat).

1 for(uint i=1; i<100; i+=3) {
2 uint _key = cache.cacheWrite(i);
3 assertEq(_key, i);
4 }
5 } // testRepeatCaching

Theoretisch könnte es einen Fehler geben, der aufeinanderfolgende Cache-Schreibvorgänge nicht betrifft. Hier führen wir also einige Schreibvorgänge durch, die nicht aufeinanderfolgend sind, und sehen, dass die Werte immer noch nicht neu geschrieben werden.

1 // Einen uint aus einem Speicherpuffer lesen (um sicherzustellen, dass wir die Parameter zurückbekommen,
2 // die wir gesendet haben)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

Lesen Sie ein 256-Bit-Wort aus einem bytes memory-Puffer. Diese Hilfsfunktion ermöglicht es uns zu überprüfen, ob wir die richtigen Ergebnisse erhalten, wenn wir einen Funktionsaufruf ausführen, der den Cache verwendet.

1 {
2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

Yul unterstützt keine Datenstrukturen jenseits von uint256. Wenn Sie sich also auf eine komplexere Datenstruktur wie den Speicherpuffer _bytes beziehen, erhalten Sie die Adresse dieser Struktur. Solidity speichert bytes memory-Werte als ein 32-Byte-Wort, das die Länge enthält, gefolgt von den eigentlichen Bytes. Um also Byte Nummer _start zu erhalten, müssen wir _bytes+32+_start berechnen.

1
2 return tempUint;
3 } // toUint256
4
5 // Funktionssignatur für fourParams(), mit freundlicher Genehmigung von
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Nur einige konstante Werte, um zu sehen, ob wir die richtigen Werte zurückbekommen
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;

Einige Konstanten, die wir zum Testen benötigen.

1 function testReadParam() public {

Rufen Sie fourParams() auf, eine Funktion, die readParams verwendet, um zu testen, ob wir Parameter korrekt lesen können.

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

Wir können nicht den normalen ABI-Mechanismus verwenden, um eine Funktion aufzurufen, die den Cache nutzt, daher müssen wir den Low-Level-Mechanismus <address>.call() (opens in a new tab) verwenden. Dieser Mechanismus nimmt ein bytes memory als Eingabe und gibt dieses (sowie einen booleschen Wert) als Ausgabe zurück.

1 // Erster Aufruf, der Cache ist leer
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

Es ist nützlich, wenn derselbe Vertrag sowohl gecachte Funktionen (für Aufrufe direkt aus Transaktionen) als auch nicht gecachte Funktionen (für Aufrufe von anderen Smart Contracts) unterstützt. Um dies zu tun, müssen wir uns weiterhin auf den Solidity-Mechanismus verlassen, um die richtige Funktion aufzurufen, anstatt alles in eine fallback-Funktion (opens in a new tab) zu packen. Dies macht die Komponierbarkeit viel einfacher. Ein einzelnes Byte würde in den meisten Fällen ausreichen, um die Funktion zu identifizieren, also verschwenden wir drei Bytes (16*3=48 Gas). Während ich dies schreibe, kosten diese 48 Gas jedoch 0,07 Cent, was ein angemessener Preis für einfacheren, weniger fehleranfälligen Code ist.

1 // Erster Wert, zum Cache hinzufügen
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

Der erste Wert: Ein Flag, das besagt, dass es sich um einen vollständigen Wert handelt, der in den Cache geschrieben werden muss, gefolgt von den 32 Bytes des Wertes. Die anderen drei Werte sind ähnlich, außer dass VAL_B nicht in den Cache geschrieben wird und VAL_C sowohl der dritte als auch der vierte Parameter ist.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

Hier rufen wir tatsächlich den Cache-Vertrag auf.

1 assertEq(_success, true);

Wir erwarten, dass der Aufruf erfolgreich ist.

1 assertEq(cache.cacheRead(1), VAL_A);
2 assertEq(cache.cacheRead(2), VAL_C);

Wir beginnen mit einem leeren Cache und fügen dann VAL_A gefolgt von VAL_C hinzu. Wir würden erwarten, dass der erste den Schlüssel 1 und der zweite den Schlüssel 2 hat.

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

Die Ausgabe sind die vier Parameter. Hier überprüfen wir, ob sie korrekt ist.

1 // Zweiter Aufruf, wir können den Cache verwenden
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // Erster Wert im Cache
6 bytes1(0x01),

Cache-Schlüssel unter 16 sind nur ein Byte groß.

1 // Zweiter Wert, nicht zum Cache hinzufügen
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Dritter und vierter Wert, gleicher Wert
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam

Die Tests nach dem Aufruf sind identisch mit denen nach dem ersten Aufruf.

1 function testEncodeVal() public {

Diese Funktion ähnelt testReadParam, außer dass wir anstelle des expliziten Schreibens der Parameter encodeVal() verwenden.

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal

Der einzige zusätzliche Test in testEncodeVal() besteht darin, zu überprüfen, ob die Länge von _callInput korrekt ist. Für den ersten Aufruf ist sie 4+33*4. Für den zweiten, bei dem jeder Wert bereits im Cache ist, ist sie 4+1*4.

1 // encodeVal testen, wenn der Schlüssel mehr als ein einzelnes Byte ist
2 // Maximal drei Bytes, da das Füllen des Caches auf vier Bytes zu lange
3 // dauert.
4 function testEncodeValBig() public {
5 // Eine Reihe von Werten in den Cache legen.
6 // Um es einfach zu halten, Schlüssel n für Wert n verwenden.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }

Die obige Funktion testEncodeVal schreibt nur vier Werte in den Cache, sodass der Teil der Funktion, der sich mit Multi-Byte-Werten befasst (opens in a new tab) nicht überprüft wird. Aber dieser Code ist kompliziert und fehleranfällig.

Der erste Teil dieser Funktion ist eine Schleife, die alle Werte von 1 bis 0x1FFF der Reihe nach in den Cache schreibt, sodass wir diese Werte kodieren können und wissen, wohin sie gehen.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // Ein Byte 0x0F
8 cache.encodeVal(0x0010), // Zwei Bytes 0x1010
9 cache.encodeVal(0x0100), // Zwei Bytes 0x1100
10 cache.encodeVal(0x1000) // Drei Bytes 0x201000
11 );

Testen Sie Ein-Byte-, Zwei-Byte- und Drei-Byte-Werte. Wir testen nicht darüber hinaus, da es zu lange dauern würde, genügend Stack-Einträge zu schreiben (mindestens 0x10000000, etwa eine Viertelmilliarde).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // Testen, dass wir bei einem übermäßig kleinen Puffer einen Revert erhalten
9 function testShortCalldata() public {

Testen Sie, was im abnormalen Fall passiert, wenn nicht genügend Parameter vorhanden sind.

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

Da es rückgängig gemacht wird (revert), sollte das Ergebnis, das wir erhalten, false sein.

1 // Call with cache keys that aren't there
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // First value, add it to the cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Second value
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );

Diese Funktion erhält vier völlig legitime Parameter, außer dass der Cache leer ist, sodass dort keine Werte zum Lesen vorhanden sind.

1 .
2 .
3 .
4 // Testen, dass bei einem übermäßig langen Puffer alles einwandfrei funktioniert
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // Erster Aufruf, der Cache ist leer
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // Erster Wert, zum Cache hinzufügen
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Zweiter Wert, zum Cache hinzufügen
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Dritter Wert, zum Cache hinzufügen
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Vierter Wert, zum Cache hinzufügen
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // Und noch ein Wert für "viel Glück"
28 bytes4(0x31112233)
29 );

Diese Funktion sendet fünf Werte. Wir wissen, dass der fünfte Wert ignoriert wird, da es sich nicht um einen gültigen Cache-Eintrag handelt, was zu einem Revert geführt hätte, wenn er nicht enthalten gewesen wäre.

Eine Beispielanwendung

Tests in Solidity zu schreiben ist schön und gut, aber am Ende des Tages muss eine Dapp in der Lage sein, Anfragen von außerhalb der Blockchain zu verarbeiten, um nützlich zu sein. Dieser Artikel demonstriert, wie man Caching in einer Dapp mit WORM verwendet, was für „Write Once, Read Many“ steht. Wenn ein Schlüssel noch nicht geschrieben wurde, können Sie einen Wert hineinschreiben. Wenn der Schlüssel bereits geschrieben wurde, erhalten Sie einen Revert.

Der Vertrag

Dies ist der Vertrag (opens in a new tab). Er wiederholt größtenteils das, was wir bereits mit Cache und CacheTest gemacht haben, daher behandeln wir nur die interessanten Teile.

1import "./Cache.sol";
2
3contract WORM is Cache {

Der einfachste Weg, Cache zu verwenden, besteht darin, ihn in unserem eigenen Vertrag zu erben.

1 function writeEntryCached() external {
2 uint[] memory params = _readParams(2);
3 writeEntry(params[0], params[1]);
4 } // writeEntryCached

Diese Funktion ähnelt fourParam in CacheTest oben. Da wir den ABI-Spezifikationen nicht folgen, ist es am besten, keine Parameter in der Funktion zu deklarieren.

1 // Es einfacher machen, uns aufzurufen
2 // Funktionssignatur für writeEntryCached(), mit freundlicher Genehmigung von
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

Der externe Code, der writeEntryCached aufruft, muss die Calldata manuell erstellen, anstatt worm.writeEntryCached zu verwenden, da wir den ABI-Spezifikationen nicht folgen. Diesen konstanten Wert zu haben, macht es einfach leichter, ihn zu schreiben.

Beachten Sie, dass wir WRITE_ENTRY_CACHED zwar als Zustandsvariable definieren, es aber zum externen Lesen notwendig ist, die Getter-Funktion dafür zu verwenden: worm.WRITE_ENTRY_CACHED().

1 function readEntry(uint key) public view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)

Die Lesefunktion ist eine view-Funktion, erfordert also keine Transaktion und kostet kein Gas. Infolgedessen gibt es keinen Vorteil, den Cache für den Parameter zu verwenden. Bei View-Funktionen ist es am besten, den Standardmechanismus zu verwenden, der einfacher ist.

Der Testcode

Dies ist der Testcode für den Vertrag (opens in a new tab). Lassen Sie uns auch hier nur das betrachten, was interessant ist.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

So (vm.expectRevert) (opens in a new tab) geben wir in einem Foundry-Test an, dass der nächste Aufruf fehlschlagen soll, sowie den gemeldeten Grund für einen Fehler. Dies gilt, wenn wir die Syntax <contract>.<function name>() verwenden, anstatt die Calldata zu erstellen und den Vertrag über die Low-Level-Schnittstelle (<contract>.call() usw.) aufzurufen.

1 function testReadWriteCached() public {
2 uint cacheGoat = worm.cacheWrite(0x60A7);

Hier nutzen wir die Tatsache, dass cacheWrite den Cache-Schlüssel zurückgibt. Dies ist nichts, was wir in der Produktion erwarten würden, da cacheWrite den Zustand ändert und daher nur während einer Transaktion aufgerufen werden kann. Transaktionen haben keine Rückgabewerte; wenn sie Ergebnisse haben, sollen diese Ergebnisse als Ereignisse ausgegeben werden. Der Rückgabewert von cacheWrite ist also nur von On-Chain-Code aus zugänglich, und On-Chain-Code benötigt kein Parameter-Caching.

1 (_success,) = address(worm).call(_callInput);

So teilen wir Solidity mit, dass <contract address>.call() zwar zwei Rückgabewerte hat, wir uns aber nur für den ersten interessieren.

1 (_success,) = address(worm).call(_callInput);
2 assertEq(_success, false);

Da wir die Low-Level-Funktion <address>.call() verwenden, können wir vm.expectRevert() nicht verwenden und müssen uns den booleschen Erfolgswert ansehen, den wir vom Aufruf erhalten.

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);

Auf diese Weise überprüfen wir in Foundry, ob Code ein Ereignis korrekt ausgibt (opens in a new tab).

Die Anwendung

Eine Sache, die Sie bei Solidity-Tests nicht bekommen, ist JavaScript-Code, den Sie ausschneiden und in Ihre eigene Anwendung einfügen können. Um diesen Code zu schreiben, habe ich WORM auf Optimism Goerli (opens in a new tab), dem neuen Testnet von Optimism (opens in a new tab), bereitgestellt. Es befindet sich unter der Adresse 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).

Sie können den JavaScript-Code für die Anwendung hier sehen (opens in a new tab). Um ihn zu verwenden:

  1. Klonen Sie das Git-Repository:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
1
22. Installieren Sie die erforderlichen Pakete:
3
4 ```sh
5 cd javascript
6 yarn
  1. Kopieren Sie die Konfigurationsdatei:

    1cp .env.example .env
1
24. Bearbeiten Sie `.env` für Ihre Konfiguration:
3
4 | Parameter | Wert |
5 | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6 | MNEMONIC | Die Mnemonic für ein Konto, das über genügend ETH verfügt, um eine Transaktion zu bezahlen. [Hier erhalten Sie kostenlose ETH für das Optimism Goerli-Netzwerk](https://optimismfaucet.xyz/). |
7 | OPTIMISM_GOERLI_URL | URL zu Optimism Goerli. Der öffentliche Endpunkt, `https://goerli.optimism.io`, ist ratenbegrenzt, aber ausreichend für das, was wir hier benötigen. |
8
95. Führen Sie `index.js` aus.
10
11 ```sh
12 node index.js

Diese Beispielanwendung schreibt zunächst einen Eintrag in WORM und zeigt die Calldata sowie einen Link zur Transaktion auf Etherscan an. Dann liest sie diesen Eintrag zurück und zeigt den verwendeten Schlüssel sowie die Werte im Eintrag an (Wert, Blocknummer und Autor).

Der Großteil der Anwendung ist normales Dapp-JavaScript. Wir werden also wieder nur die interessanten Teile durchgehen.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Benötigt jedes Mal einen neuen Schlüssel
8 const key = await worm.encodeVal(Number(new Date()))

In einen bestimmten Slot kann nur einmal geschrieben werden, daher verwenden wir den Zeitstempel, um sicherzustellen, dass wir Slots nicht wiederverwenden.

1const val = await worm.encodeVal("0x600D")
2
3// Einen Eintrag schreiben
4const calldata = func + key.slice(2) + val.slice(2)

Ethers erwartet, dass die Call-Daten ein Hex-String sind, 0x gefolgt von einer geraden Anzahl hexadezimaler Ziffern. Da key und val beide mit 0x beginnen, müssen wir diese Header entfernen.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

Wie beim Solidity-Testcode können wir eine gecachte Funktion nicht normal aufrufen. Stattdessen müssen wir einen Low-Level-Mechanismus verwenden.

1 .
2 .
3 .
4 // Den gerade geschriebenen Eintrag lesen
5 const realKey = '0x' + key.slice(4) // das FF-Flag entfernen
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .

Zum Lesen von Einträgen können wir den normalen Mechanismus verwenden. Es besteht keine Notwendigkeit, Parameter-Caching bei view-Funktionen zu verwenden.

Fazit

Der Code in diesem Artikel ist ein Proof of Concept; der Zweck ist es, die Idee leicht verständlich zu machen. Für ein produktionsreifes System möchten Sie vielleicht einige zusätzliche Funktionen implementieren:

  • Behandeln Sie Werte, die nicht uint256 sind. Zum Beispiel Strings.

  • Anstelle eines globalen Caches könnte es ein Mapping zwischen Benutzern und Caches geben. Verschiedene Benutzer verwenden unterschiedliche Werte.

  • Werte, die für Adressen verwendet werden, unterscheiden sich von denen, die für andere Zwecke verwendet werden. Es könnte sinnvoll sein, einen separaten Cache nur für Adressen zu haben.

  • Derzeit basieren die Cache-Schlüssel auf einem „Wer zuerst kommt, erhält den kleinsten Schlüssel“-Algorithmus. Die ersten sechzehn Werte können als einzelnes Byte gesendet werden. Die nächsten 4080 Werte können als zwei Bytes gesendet werden. Die nächsten etwa eine Million Werte sind drei Bytes usw. Ein Produktionssystem sollte Nutzungszähler für Cache-Einträge führen und diese so reorganisieren, dass die sechzehn häufigsten Werte ein Byte sind, die nächsten 4080 häufigsten Werte zwei Bytes usw.

    Dies ist jedoch eine potenziell gefährliche Operation. Stellen Sie sich die folgende Abfolge von Ereignissen vor:

    1. Noam Naive ruft encodeVal auf, um die Adresse zu kodieren, an die er Token senden möchte. Diese Adresse ist eine der ersten, die in der Anwendung verwendet wird, daher ist der kodierte Wert 0x06. Dies ist eine view-Funktion, keine Transaktion, also findet sie zwischen Noam und dem von ihm genutzten Blockchain-Knoten statt, und niemand sonst weiß davon.

    2. Owen Owner führt die Cache-Neuordnungsoperation aus. Sehr wenige Leute verwenden diese Adresse tatsächlich, daher wird sie jetzt als 0x201122 kodiert. Einem anderen Wert, 1018, wird 0x06 zugewiesen.

    3. Noam Naive sendet seine Token an 0x06. Sie gehen an die Adresse 0x0000000000000000000000000de0b6b3a7640000, und da niemand den Private-Key für diese Adresse kennt, stecken sie dort einfach fest. Noam ist nicht glücklich.

    Es gibt Möglichkeiten, dieses Problem und das damit verbundene Problem von Transaktionen, die sich während der Cache-Neuordnung im Mempool befinden, zu lösen, aber Sie müssen sich dessen bewusst sein.

Ich habe Caching hier mit Optimism demonstriert, weil ich ein Mitarbeiter von Optimism bin und dies das Rollups ist, das ich am besten kenne. Aber es sollte mit jedem Rollups funktionieren, das minimale Kosten für die interne Verarbeitung berechnet, sodass im Vergleich dazu das Schreiben der Transaktionsdaten auf L1 der größte Kostenfaktor ist.

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

Letzte Aktualisierung der Seite: 3. März 2026

War dieses Tutorial hilfreich?