Tutto ciò che puoi mettere in cache
Quando si utilizzano i rollup, il costo di un byte nella transazione è molto più costoso del costo di uno slot di archiviazione. Pertanto, ha senso memorizzare nella cache quante più informazioni possibili on-chain.
In questo articolo imparerai come creare e utilizzare un contratto di caching in modo tale che qualsiasi valore di parametro che probabilmente verrà utilizzato più volte venga memorizzato nella cache e reso disponibile per l'uso (dopo la prima volta) con un numero molto inferiore di byte, e come scrivere codice fuori catena che utilizzi questa cache.
Se vuoi saltare l'articolo e vedere solo il codice sorgente, è qui (opens in a new tab). Lo stack di sviluppo è Foundry (opens in a new tab).
Design generale
Per semplicità, supporremo che tutti i parametri della transazione siano uint256, lunghi 32 byte. Quando riceviamo una transazione, analizzeremo ogni parametro in questo modo:
-
Se il primo byte è
0xFF, prendi i successivi 32 byte come valore del parametro e scrivilo nella cache. -
Se il primo byte è
0xFE, prendi i successivi 32 byte come valore del parametro ma non scriverlo nella cache. -
Per qualsiasi altro valore, prendi i primi quattro bit come numero di byte aggiuntivi e gli ultimi quattro bit come bit più significativi della chiave della cache. Ecco alcuni esempi:
Byte in calldata Chiave della cache 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Manipolazione della cache
La cache è implementata in Cache.sol (opens in a new tab). Esaminiamola riga per riga.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 bytes1 public constant INTO_CACHE = 0xFF;8 bytes1 public constant DONT_CACHE = 0xFE;Queste costanti sono utilizzate per interpretare i casi speciali in cui forniamo tutte le informazioni e vogliamo che vengano scritte nella cache o meno. Scrivere nella cache richiede due operazioni SSTORE (opens in a new tab) in slot di archiviazione precedentemente inutilizzati a un costo di 22100 gas ciascuna, quindi lo rendiamo opzionale.
12 mapping(uint => uint) public val2key;Una mappatura (opens in a new tab) tra i valori e le loro chiavi. Questa informazione è necessaria per codificare i valori prima di inviare la transazione.
1 // La posizione n ha il valore per la chiave n+1, perché dobbiamo preservare2 // lo zero come "non nella cache".3 uint[] public key2val;Possiamo usare un array per la mappatura dalle chiavi ai valori perché assegniamo noi le chiavi e, per semplicità, lo facciamo in modo sequenziale.
1 function cacheRead(uint _key) public view returns (uint) {2 require(_key <= key2val.length, "Reading uninitialize cache entry");3 return key2val[_key-1];4 } // cacheReadLegge un valore dalla cache.
1 // Scrive un valore nella cache se non è già presente2 // Pubblico solo per permettere al test di funzionare3 function cacheWrite(uint _value) public returns (uint) {4 // Se il valore è già nella cache, restituisce la chiave corrente5 if (val2key[_value] != 0) {6 return val2key[_value];7 }Non ha senso inserire lo stesso valore nella cache più di una volta. Se il valore è già presente, restituisce semplicemente la chiave esistente.
1 // Poiché 0xFE è un caso speciale, la chiave più grande che la cache può2 // contenere è 0x0D seguito da 15 0xFF. Se la lunghezza della cache è già così3 // grande, fallisce.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "cache overflow");Non credo che avremo mai una cache così grande (circa 1.8*1037 voci, che richiederebbero circa 1027 TB per l'archiviazione). Tuttavia, sono abbastanza vecchio da ricordare che "640kB sarebbero sempre stati sufficienti" (opens in a new tab). Questo test è molto economico.
1 // Scrive il valore usando la chiave successiva2 val2key[_value] = key2val.length+1;Aggiunge la ricerca inversa (dal valore alla chiave).
1 key2val.push(_value);Aggiunge la ricerca in avanti (dalla chiave al valore). Poiché assegniamo i valori in modo sequenziale, possiamo semplicemente aggiungerlo dopo l'ultimo valore dell'array.
1 return key2val.length;2 } // cacheWriteRestituisce la nuova lunghezza di key2val, che è la cella in cui è memorizzato il nuovo valore.
1 function _calldataVal(uint startByte, uint length)2 private pure returns (uint)Questa funzione legge un valore dal calldata di lunghezza arbitraria (fino a 32 byte, la dimensione della parola).
1 {2 uint _retVal;34 require(length < 0x21,5 "_calldataVal length limit is 32 bytes");6 require(length + startByte <= msg.data.length,7 "_calldataVal trying to read beyond calldatasize");Questa funzione è interna, quindi se il resto del codice è scritto correttamente questi test non sono necessari. Tuttavia, non costano molto, quindi tanto vale averli.
1 assembly {2 _retVal := calldataload(startByte)3 }Questo codice è in Yul (opens in a new tab). Legge un valore di 32 byte dal calldata. Questo funziona anche se il calldata si ferma prima di startByte+32 perché lo spazio non inizializzato nell'EVM è considerato pari a zero.
1 _retVal = _retVal >> (256-length*8);Non vogliamo necessariamente un valore di 32 byte. Questo elimina i byte in eccesso.
1 return _retVal;2 } // _calldataVal345 // Legge un singolo parametro dalla calldata, partendo da _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Legge un singolo parametro dal calldata. Nota che dobbiamo restituire non solo il valore che abbiamo letto, ma anche la posizione del byte successivo perché i parametri possono variare da 1 byte a 33 byte di lunghezza.
1 // Il primo byte ci dice come interpretare il resto2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));Solidity cerca di ridurre il numero di bug vietando conversioni di tipo implicite (opens in a new tab) potenzialmente pericolose. Un declassamento, ad esempio da 256 bit a 8 bit, deve essere esplicito.
12 // Legge il valore, ma non lo scrive nella cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Legge il valore e lo scrive nella cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Se siamo arrivati qui significa che dobbiamo leggere dalla cache1415 // Numero di byte extra da leggere16 uint8 _extraBytes = _firstByte / 16;Mostra tuttoPrende il nibble (opens in a new tab) inferiore e lo combina con gli altri byte per leggere il valore dalla cache.
1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +2 _calldataVal(_fromByte+1, _extraBytes);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Legge n parametri (le funzioni sanno quanti parametri si aspettano)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Mostra tuttoPotremmo ottenere il numero di parametri che abbiamo dal calldata stesso, ma le funzioni che ci chiamano sanno quanti parametri si aspettano. È più facile lasciare che ce lo dicano loro.
1 // I parametri che leggiamo2 uint[] memory params = new uint[](_paramNum);34 // I parametri iniziano al byte 4, prima c'è la firma della funzione5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Mostra tuttoLegge i parametri finché non hai il numero di cui hai bisogno. Se andiamo oltre la fine del calldata, _readParams annullerà la chiamata.
12 return(params);3 } // readParams45 // Per testare _readParams, testa la lettura di quattro parametri6 function fourParam() public7 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 } // fourParamMostra tuttoUn grande vantaggio di Foundry è che consente di scrivere test in Solidity (vedi Testare la cache di seguito). Questo rende i test unitari molto più semplici. Questa è una funzione che legge quattro parametri e li restituisce in modo che il test possa verificare che fossero corretti.
1 // Ottiene un valore, restituisce i byte che lo codificheranno (usando la cache se possibile)2 function encodeVal(uint _val) public view returns(bytes memory) {encodeVal è una funzione che il codice fuori catena chiama per aiutare a creare calldata che utilizza la cache. Riceve un singolo valore e restituisce i byte che lo codificano. Questa funzione è una view, quindi non richiede una transazione e quando chiamata esternamente non costa alcun gas.
1 uint _key = val2key[_val];23 // Il valore non è ancora nella cache, lo aggiunge4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));Nell'EVM (macchina virtuale di Ethereum) tutta l'archiviazione non inizializzata si presume sia composta da zeri. Quindi, se cerchiamo la chiave per un valore che non c'è, otteniamo uno zero. In tal caso i byte che lo codificano sono INTO_CACHE (in modo che venga memorizzato nella cache la prossima volta), seguiti dal valore effettivo.
1 // Se la chiave è <0x10, la restituisce come singolo byte2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));I byte singoli sono i più facili. Usiamo semplicemente bytes.concat (opens in a new tab) per trasformare un tipo bytes<n> in un array di byte che può essere di qualsiasi lunghezza. Nonostante il nome, funziona bene quando viene fornito con un solo argomento.
1 // Valore a due byte, codificato come 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));Quando abbiamo una chiave inferiore a 163, possiamo esprimerla in due byte. Per prima cosa convertiamo _key, che è un valore a 256 bit, in un valore a 16 bit e usiamo l'OR logico per aggiungere il numero di byte extra al primo byte. Quindi lo inseriamo semplicemente in un valore bytes2, che può essere convertito in bytes.
1 // Probabilmente c'è un modo intelligente per eseguire le righe seguenti come un ciclo,2 // ma è una funzione view, quindi sto ottimizzando per il tempo del programmatore e3 // la semplicità.45 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)));Mostra tuttoGli altri valori (3 byte, 4 byte, ecc.) vengono gestiti allo stesso modo, solo con dimensioni del campo diverse.
1 // Se arriviamo qui, c'è qualcosa di sbagliato.2 revert("Error in encodeVal, should not happen");Se arriviamo qui significa che abbiamo ottenuto una chiave che non è inferiore a 16*25615. Ma cacheWrite limita le chiavi, quindi non possiamo nemmeno arrivare a 14*25616 (che avrebbe un primo byte di 0xFE, quindi sembrerebbe DONT_CACHE). Ma non ci costa molto aggiungere un test nel caso in cui un programmatore futuro introduca un bug.
1 } // encodeVal23} // CacheTestare la cache
Uno dei vantaggi di Foundry è che ti permette di scrivere test in Solidity (opens in a new tab), il che rende più facile scrivere test unitari. I test per la classe Cache sono qui (opens in a new tab). Poiché il codice di test è ripetitivo, come tendono a essere i test, questo articolo spiega solo le parti interessanti.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// È necessario eseguire `forge test -vv` per la console.8import "forge-std/console.sol";Questo è solo codice boilerplate necessario per utilizzare il pacchetto di test e console.log.
1import "src/Cache.sol";Dobbiamo conoscere il contratto che stiamo testando.
1contract CacheTest is Test {2 Cache cache;34 function setUp() public {5 cache = new Cache();6 }La funzione setUp viene chiamata prima di ogni test. In questo caso creiamo semplicemente una nuova cache, in modo che i nostri test non si influenzino a vicenda.
1 function testCaching() public {I test sono funzioni i cui nomi iniziano con test. Questa funzione controlla la funzionalità di base della cache, scrivendo valori e leggendoli di nuovo.
1 for(uint i=1; i<5000; i++) {2 cache.cacheWrite(i*i);3 }45 for(uint i=1; i<5000; i++) {6 assertEq(cache.cacheRead(i), i*i);Ecco come si esegue il test vero e proprio, utilizzando le funzioni assert... (opens in a new tab). In questo caso, controlliamo che il valore che abbiamo scritto sia quello che abbiamo letto. Possiamo scartare il risultato di cache.cacheWrite perché sappiamo che le chiavi della cache vengono assegnate in modo lineare.
1 }2 } // testCaching345 // Mette in cache lo stesso valore più volte, assicurandosi che la chiave rimanga6 // la stessa7 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 }Mostra tuttoPer prima cosa scriviamo ogni valore due volte nella cache e ci assicuriamo che le chiavi siano le stesse (il che significa che la seconda scrittura non è avvenuta realmente).
1 for(uint i=1; i<100; i+=3) {2 uint _key = cache.cacheWrite(i);3 assertEq(_key, i);4 }5 } // testRepeatCachingIn teoria potrebbe esserci un bug che non influisce sulle scritture consecutive nella cache. Quindi qui facciamo alcune scritture che non sono consecutive e vediamo che i valori non vengono comunque riscritti.
1 // Legge un uint da un buffer di memoria (per assicurarsi di riavere i parametri2 // che abbiamo inviato)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)Legge una parola a 256 bit da un buffer bytes memory. Questa funzione di utilità ci consente di verificare di ricevere i risultati corretti quando eseguiamo una chiamata di funzione che utilizza la cache.
1 {2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");3 uint256 tempUint;45 assembly {6 tempUint := mload(add(add(_bytes, 0x20), _start))7 }Yul non supporta strutture dati oltre a uint256, quindi quando ti riferisci a una struttura dati più sofisticata, come il buffer di memoria _bytes, ottieni l'indirizzo di quella struttura. Solidity memorizza i valori bytes memory come una parola di 32 byte che contiene la lunghezza, seguita dai byte effettivi, quindi per ottenere il numero di byte _start dobbiamo calcolare _bytes+32+_start.
12 return tempUint;3 } // toUint25645 // Firma della funzione per fourParams(), per gentile concessione di6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Solo alcuni valori costanti per vedere che stiamo ottenendo indietro i valori corretti10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Mostra tuttoAlcune costanti di cui abbiamo bisogno per i test.
1 function testReadParam() public {Chiama fourParams(), una funzione che utilizza readParams, per testare che possiamo leggere i parametri correttamente.
1 address _cacheAddr = address(cache);2 bool _success;3 bytes memory _callInput;4 bytes memory _callOutput;Non possiamo usare il normale meccanismo ABI per chiamare una funzione usando la cache, quindi dobbiamo usare il meccanismo di basso livello <address>.call() (opens in a new tab). Quel meccanismo accetta un bytes memory come input e lo restituisce (insieme a un valore booleano) come output.
1 // Prima chiamata, la cache è vuota2 _callInput = bytes.concat(3 FOUR_PARAMS,È utile che lo stesso contratto supporti sia funzioni memorizzate nella cache (per chiamate direttamente dalle transazioni) sia funzioni non memorizzate nella cache (per chiamate da altri contratti intelligenti). Per farlo dobbiamo continuare ad affidarci al meccanismo di Solidity per chiamare la funzione corretta, invece di inserire tutto in una funzione di fallback (opens in a new tab). Fare questo rende la componibilità molto più semplice. Un singolo byte sarebbe sufficiente per identificare la funzione nella maggior parte dei casi, quindi stiamo sprecando tre byte (16*3=48 gas). Tuttavia, mentre scrivo questo, quei 48 gas costano 0,07 centesimi, che è un costo ragionevole per un codice più semplice e meno soggetto a bug.
1 // Primo valore, lo aggiunge alla cache2 cache.INTO_CACHE(),3 bytes32(VAL_A),Il primo valore: un flag che indica che è un valore completo che deve essere scritto nella cache, seguito dai 32 byte del valore. Gli altri tre valori sono simili, tranne per il fatto che VAL_B non viene scritto nella cache e VAL_C è sia il terzo parametro che il quarto.
1 .2 .3 .4 );5 (_success, _callOutput) = _cacheAddr.call(_callInput);È qui che chiamiamo effettivamente il contratto Cache.
1 assertEq(_success, true);Ci aspettiamo che la chiamata abbia successo.
1 assertEq(cache.cacheRead(1), VAL_A);2 assertEq(cache.cacheRead(2), VAL_C);Iniziamo con una cache vuota e poi aggiungiamo VAL_A seguito da VAL_C. Ci aspetteremmo che il primo abbia la chiave 1 e il secondo abbia 2.
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);L'output sono i quattro parametri. Qui verifichiamo che sia corretto.
1 // Seconda chiamata, possiamo usare la cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // Primo valore nella Cache6 bytes1(0x01),Le chiavi della cache inferiori a 16 sono di un solo byte.
1 // Secondo valore, non lo aggiunge alla cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Terzo e quarto valore, stesso valore6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamMostra tuttoI test dopo la chiamata sono identici a quelli dopo la prima chiamata.
1 function testEncodeVal() public {Questa funzione è simile a testReadParam, tranne per il fatto che invece di scrivere i parametri esplicitamente usiamo encodeVal().
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 } // testEncodeValMostra tuttoL'unico test aggiuntivo in testEncodeVal() è verificare che la lunghezza di _callInput sia corretta. Per la prima chiamata è 4+33*4. Per la seconda, dove ogni valore è già nella cache, è 4+1*4.
1 // Testa encodeVal quando la chiave è più di un singolo byte2 // Massimo tre byte perché riempire la cache a quattro byte richiede3 // troppo tempo.4 function testEncodeValBig() public {5 // Mette un certo numero di valori nella cache.6 // Per mantenere le cose semplici, usa la chiave n per il valore n.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }Mostra tuttoLa funzione testEncodeVal sopra scrive solo quattro valori nella cache, quindi la parte della funzione che gestisce i valori multi-byte (opens in a new tab) non viene controllata. Ma quel codice è complicato e soggetto a errori.
La prima parte di questa funzione è un ciclo che scrive tutti i valori da 1 a 0x1FFF nella cache in ordine, in modo da poter codificare quei valori e sapere dove stanno andando.
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // Un byte 0x0F8 cache.encodeVal(0x0010), // Due byte 0x10109 cache.encodeVal(0x0100), // Due byte 0x110010 cache.encodeVal(0x1000) // Tre byte 0x20100011 );Mostra tuttoTesta valori di un byte, due byte e tre byte. Non testiamo oltre perché ci vorrebbe troppo tempo per scrivere abbastanza voci nello stack (almeno 0x10000000, circa un quarto di miliardo).
1 .2 .3 .4 .5 } // testEncodeValBig678 // Testa che con un buffer eccessivamente piccolo otteniamo un revert9 function testShortCalldata() public {Mostra tuttoTesta cosa succede nel caso anomalo in cui non ci sono abbastanza parametri.
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldataPoiché si annulla, il risultato che dovremmo ottenere è false.
1 // Call with cache keys that aren't there2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // First value, add it to the cache10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Second value14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );Mostra tuttoQuesta funzione ottiene quattro parametri perfettamente legittimi, tranne per il fatto che la cache è vuota, quindi non ci sono valori da leggere.
1 .2 .3 .4 // Testa che con un buffer eccessivamente lungo tutto funziona correttamente5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // Prima chiamata, la cache è vuota12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // Primo valore, lo aggiunge alla cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Secondo valore, lo aggiunge alla cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Terzo valore, lo aggiunge alla cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Quarto valore, lo aggiunge alla cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // E un altro valore per "buona fortuna"28 bytes4(0x31112233)29 );Mostra tuttoQuesta funzione invia cinque valori. Sappiamo che il quinto valore viene ignorato perché non è una voce di cache valida, il che avrebbe causato un annullamento se non fosse stato incluso.
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9Mostra tuttoUn'applicazione di esempio
Scrivere test in Solidity va benissimo, ma alla fine della giornata una dApp deve essere in grado di elaborare richieste dall'esterno della catena per essere utile. Questo articolo dimostra come utilizzare il caching in una dApp con WORM, che sta per "Write Once, Read Many" (Scrivi una volta, leggi molte). Se una chiave non è ancora stata scritta, puoi scriverci un valore. Se la chiave è già scritta, ottieni un annullamento.
Il contratto
Questo è il contratto (opens in a new tab). Ripete per lo più ciò che abbiamo già fatto con Cache e CacheTest, quindi copriamo solo le parti interessanti.
1import "./Cache.sol";23contract WORM is Cache {Il modo più semplice per usare Cache è ereditarlo nel nostro contratto.
1 function writeEntryCached() external {2 uint[] memory params = _readParams(2);3 writeEntry(params[0], params[1]);4 } // writeEntryCachedQuesta funzione è simile a fourParam in CacheTest sopra. Poiché non seguiamo le specifiche ABI, è meglio non dichiarare alcun parametro nella funzione.
1 // Rende più facile chiamarci2 // Firma della funzione per writeEntryCached(), per gentile concessione di3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;Il codice esterno che chiama writeEntryCached dovrà costruire manualmente il calldata, invece di usare worm.writeEntryCached, perché non seguiamo le specifiche ABI. Avere questo valore costante rende semplicemente più facile scriverlo.
Nota che anche se definiamo WRITE_ENTRY_CACHED come una variabile di stato, per leggerla esternamente è necessario utilizzare la sua funzione getter, worm.WRITE_ENTRY_CACHED().
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)La funzione di lettura è una view, quindi non richiede una transazione e non costa gas. Di conseguenza, non c'è alcun vantaggio nell'usare la cache per il parametro. Con le funzioni view è meglio usare il meccanismo standard che è più semplice.
Il codice di test
Questo è il codice di test per il contratto (opens in a new tab). Ancora una volta, guardiamo solo ciò che è interessante.
1 function testWReadWrite() public {2 worm.writeEntry(0xDEAD, 0x60A7);34 vm.expectRevert(bytes("entry already written"));5 worm.writeEntry(0xDEAD, 0xBEEF);Questo (vm.expectRevert) (opens in a new tab) è il modo in cui specifichiamo in un test di Foundry che la chiamata successiva dovrebbe fallire, e il motivo riportato per un fallimento. Questo si applica quando usiamo la sintassi <contract>.<function name>() piuttosto che costruire il calldata e chiamare il contratto usando l'interfaccia di basso livello (<contract>.call(), ecc.).
1 function testReadWriteCached() public {2 uint cacheGoat = worm.cacheWrite(0x60A7);Qui usiamo il fatto che cacheWrite restituisce la chiave della cache. Questo non è qualcosa che ci aspetteremmo di usare in produzione, perché cacheWrite cambia lo stato, e quindi può essere chiamato solo durante una transazione. Le transazioni non hanno valori di ritorno, se hanno risultati si suppone che quei risultati vengano emessi come eventi. Quindi il valore di ritorno di cacheWrite è accessibile solo dal codice on-chain, e il codice on-chain non ha bisogno del caching dei parametri.
1 (_success,) = address(worm).call(_callInput);Questo è il modo in cui diciamo a Solidity che mentre <contract address>.call() ha due valori di ritorno, a noi interessa solo il primo.
1 (_success,) = address(worm).call(_callInput);2 assertEq(_success, false);Poiché usiamo la funzione di basso livello <address>.call(), non possiamo usare vm.expectRevert() e dobbiamo guardare il valore booleano di successo che otteniamo dalla chiamata.
1 event EntryWritten(uint indexed key, uint indexed value);23 .4 .5 .67 _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);Mostra tuttoQuesto è il modo in cui verifichiamo che il codice emetta un evento correttamente (opens in a new tab) in Foundry.
Il client
Una cosa che non ottieni con i test di Solidity è il codice JavaScript che puoi tagliare e incollare nella tua applicazione. Per scrivere quel codice ho distribuito WORM su Optimism Goerli (opens in a new tab), la nuova rete di test di Optimism (opens in a new tab). Si trova all'indirizzo 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).
Puoi vedere il codice JavaScript per il client qui (opens in a new tab). Per usarlo:
-
Clona il repository git:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
122. Installa i pacchetti necessari:34 ```sh5 cd javascript6 yarn-
Copia il file di configurazione:
1cp .env.example .env
124. Modifica `.env` per la tua configurazione:34 | Parametro | Valore |5 | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |6 | MNEMONIC | La frase mnemonica per un account che ha abbastanza ETH per pagare una transazione. [Puoi ottenere ETH gratuiti per la rete Optimism Goerli qui](https://optimismfaucet.xyz/). |7 | OPTIMISM_GOERLI_URL | URL per Optimism Goerli. L'endpoint pubblico, `https://goerli.optimism.io`, ha un limite di velocità ma è sufficiente per ciò di cui abbiamo bisogno qui |895. Esegui `index.js`.1011 ```sh12 node index.jsMostra tuttoQuesta applicazione di esempio scrive prima una voce in WORM, visualizzando il calldata e un link alla transazione su Etherscan. Quindi rilegge quella voce e visualizza la chiave che utilizza e i valori nella voce (valore, numero del blocco e autore).
La maggior parte del client è normale JavaScript per dApp. Quindi, ancora una volta, esamineremo solo le parti interessanti.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Serve una nuova chiave ogni volta8 const key = await worm.encodeVal(Number(new Date()))Un determinato slot può essere scritto solo una volta, quindi usiamo il timestamp per assicurarci di non riutilizzare gli slot.
1const val = await worm.encodeVal("0x600D")23// Scrive una voce4const calldata = func + key.slice(2) + val.slice(2)Ethers si aspetta che i dati della chiamata siano una stringa esadecimale, 0x seguita da un numero pari di cifre esadecimali. Poiché sia key che val iniziano con 0x, dobbiamo rimuovere quelle intestazioni.
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata34sentTx = await wallet.sendTransaction(tx)Come con il codice di test di Solidity, non possiamo chiamare normalmente una funzione memorizzata nella cache. Invece, dobbiamo usare un meccanismo di livello inferiore.
1 .2 .3 .4 // Legge la voce appena scritta5 const realKey = '0x' + key.slice(4) // rimuove il flag FF6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Mostra tuttoPer leggere le voci possiamo usare il meccanismo normale. Non c'è bisogno di usare il caching dei parametri con le funzioni view.
Conclusione
Il codice in questo articolo è una prova di concetto, lo scopo è rendere l'idea facile da capire. Per un sistema pronto per la produzione potresti voler implementare alcune funzionalità aggiuntive:
-
Gestire valori che non sono
uint256. Ad esempio, le stringhe. -
Invece di una cache globale, magari avere una mappatura tra utenti e cache. Utenti diversi usano valori diversi.
-
I valori utilizzati per gli indirizzi sono distinti da quelli utilizzati per altri scopi. Potrebbe avere senso avere una cache separata solo per gli indirizzi.
-
Attualmente, le chiavi della cache si basano su un algoritmo "primo arrivato, chiave più piccola". I primi sedici valori possono essere inviati come un singolo byte. I successivi 4080 valori possono essere inviati come due byte. Il successivo milione circa di valori sono tre byte, ecc. Un sistema di produzione dovrebbe mantenere contatori di utilizzo sulle voci della cache e riorganizzarle in modo che i sedici valori più comuni siano di un byte, i successivi 4080 valori più comuni di due byte, ecc.
Tuttavia, questa è un'operazione potenzialmente pericolosa. Immagina la seguente sequenza di eventi:
-
Noam Naive chiama
encodeValper codificare l'indirizzo a cui vuole inviare i token. Quell'indirizzo è uno dei primi utilizzati sull'applicazione, quindi il valore codificato è 0x06. Questa è una funzioneview, non una transazione, quindi è tra Noam e il nodo che utilizza, e nessun altro ne è a conoscenza. -
Owen Owner esegue l'operazione di riordino della cache. Pochissime persone utilizzano effettivamente quell'indirizzo, quindi ora è codificato come 0x201122. A un valore diverso, 1018, viene assegnato 0x06.
-
Noam Naive invia i suoi token a 0x06. Vanno all'indirizzo
0x0000000000000000000000000de0b6b3a7640000, e poiché nessuno conosce la chiave privata per quell'indirizzo, rimangono semplicemente bloccati lì. Noam non è felice.
Ci sono modi per risolvere questo problema, e il problema correlato delle transazioni che si trovano nella mempool durante il riordino della cache, ma devi esserne consapevole.
-
Ho dimostrato il caching qui con Optimism, perché sono un dipendente di Optimism e questo è il rollup che conosco meglio. Ma dovrebbe funzionare con qualsiasi rollup che addebita un costo minimo per l'elaborazione interna, in modo che in confronto la scrittura dei dati della transazione su L1 sia la spesa maggiore.
Vedi qui per altri miei lavori (opens in a new tab).
Ultimo aggiornamento della pagina: 25 febbraio 2026