Salva nella cache quanto vuoi
Utilizzando i rollup, il costo di un byte nella transazione è molto maggiore di quello di uno spazio d'archiviazione. Dunque, ha senso salvare nella cache quante più informazioni possibili sulla catena.
In questo articolo imparerai come creare e utilizzare un contratto di memorizzazione nella cache, in modo tale che il valore di ogni parametro che è probabile sia utilizzato più volte sarà salvato nella cache e disponibile all'uso (dopo la prima volta), con un numero di byte molto inferiore, e come scrivere il codice fuori catena che utilizza tale cache.
Se desideri saltare l'articolo e visualizzare soltanto il codice sorgente, lo trovi qui(opens in a new tab). Lo stack di sviluppo è Foundry(opens in a new tab).
Design generale
Per semplicità supponiamo che tutti i parametri delle transazioni siano uint256
, lunghi 32 byte. Quando riceviamo una transazione, analizziamo ogni parametro come segue:
Se il primo byte è
0xFF
, prendi i successivi 32 byte come valore di parametro e scrivilo nella cache.Se il primo byte è
0xFE
, prendi i successivi 32 byte come valore del parametro e non scriverli nella cache.Per qualsiasi altro valore, prendi i primi quattro bit come numero di byte aggiuntivi e gli ultimi quattro come i bit più significativi della chiave di cache. Ecco alcuni esempi:
Byte nei 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). Analizziamolo 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;Copia
Queste costanti sono utilizzate per interpretare i casi speciali in cui forniamo tutte le informazioni e se desideriamo scriverle o no nella cache. Scrivere nella cache richiede due operazioni SSTORE
(opens in a new tab) negli spazi d'archiviazione precedentemente inutilizzati, al costo di 22100 gas l'uno, quindi lo rendiamo facoltativo.
12 mapping(uint => uint) public val2key;Copia
Una mappatura(opens in a new tab) tra i valori e le loro chiavi. Queste informazioni sono necessarie per codificare i valori prima di inviare la transazione.
1 // Location n has the value for key n+1, because we need to preserve2 // zero as "not in the cache".3 uint[] public key2val;Copia
Possiamo utilizzare un insieme per la mappatura dalle chiavi ai valori, perché assegniamo le chiavi e, per semplicità, lo facciamo sequenzialmente.
1 function cacheRead(uint _key) public view returns (uint) {2 require(_key <= key2val.length, "Reading uninitialize cache entry");3 return key2val[_key-1];4 } // cacheReadCopia
Legge un valore dalla cache.
1 // Write a value to the cache if it's not there already2 // Only public to enable the test to work3 function cacheWrite(uint _value) public returns (uint) {4 // If the value is already in the cache, return the current key5 if (val2key[_value] != 0) {6 return val2key[_value];7 }Copia
Non ha senso mettere lo stesso valore nella cache più di una volta. Se il valore è già presente, basta restituire la chiave esistente.
1 // Since 0xFE is a special case, the largest key the cache can2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that3 // large, fail.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "cache overflow");Copia
Non penso che otterremo mai una cache così grande (approssimativamente 1,8*1037 voci, che richiederebbero circa 1027 TB per l'archiviazione). Tuttavia, sono abbastanza anziano da ricordare che "640 kB saranno sempre sufficienti"(opens in a new tab). Questo test è molto economico.
1 // Write the value using the next key2 val2key[_value] = key2val.length+1;Copia
Aggiungi la ricerca inversa (dal valore alla chiave).
1 key2val.push(_value);Copia
Aggiungi la ricerca inversa (dalla chiave al valore). Poiché assegniamo i valori sequenzialmente, possiamo semplicemente aggiungerli dopo il valore dell'ultimo insieme.
1 return key2val.length;2 } // cacheWriteCopia
Restituisce la nuova lunghezza di key2val
, la cella in cui è memorizzato il nuovo valore.
1 function _calldataVal(uint startByte, uint length)2 private pure returns (uint)Copia
Questa funzione legge un valore dai calldata di lunghezza arbitraria (fino a 32 byte, le dimensioni delle parole).
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");Copia
Questa funzione è interna, quindi se il resto del codice è scritto correttamente, questi test non sono necessari. Tuttavia, non costano molto, quindi potremmo anche averli.
1 assembly {2 _retVal := calldataload(startByte)3 }Copia
Questo codice è in Yul(opens in a new tab). Legge un valore di 32 byte dai calldata. Ciò funziona anche se i calldata si fermano prima di startByte+32
, poiché lo spazio non inizializzato nell'EVM è considerato pari a zero.
1 _retVal = _retVal >> (256-length*8);Copia
Non vogliamo necessariamente un valore di 32 byte. Questo elimina i byte in eccesso.
1 return _retVal;2 } // _calldataVal345 // Read a single parameter from the calldata, starting at _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Copia
Legge un singolo parametro dai calldata. Nota che dobbiamo restituire non soltanto il valore che leggiamo, ma anche la posizione di quello successivo, poiché i parametri possono andare da una lunghezza di 1 byte a 33 byte.
1 // The first byte tells us how to interpret the rest2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));Copia
Solidity prova a ridurre il numero di bug vietando le conversioni di tipo implicito(opens in a new tab) potenzialmente pericolose. Un downgrade, ad esempio da 256 bit a 8 bit, dev'essere esplicito.
12 // Read the value, but do not write it to the cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Read the value, and write it to the cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // If we got here it means that we need to read from the cache1415 // Number of extra bytes to read16 uint8 _extraBytes = _firstByte / 16;Mostra tuttoCopia
Prendi un nibble(opens in a new tab) inferiore e combinalo con 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 // Read n parameters (functions know how many parameters they expect)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Mostra tuttoCopia
Potremmo ottenere il numero di parametri dagli stessi calldata, ma le funzioni che ci chiamano sanno quanti parametri sono previsti. È più facile lasciarcelo dire.
1 // The parameters we read2 uint[] memory params = new uint[](_paramNum);34 // Parameters start at byte 4, before that it's the function signature5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Mostra tuttoCopia
Leggi i parametri finché non ottieni il numero desiderato. Se andiamo oltre la fine dei calldata, _readParams
ripristinerà la chiamata.
12 return(params);3 } // readParams45 // For testing _readParams, test reading four parameters6 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 tuttoCopia
Un grande vantaggio di Foundry è che consente la scrittura dei test in Solidity (vedi Testare la cache di seguito). Questo semplifica molto i test unitari. Questa è una funzione che legge quattro parametri e li restituisce in modo che il test possa verificare che siano corretti.
1 // Get a value, return bytes that will encode it (using the cache if possible)2 function encodeVal(uint _val) public view returns(bytes memory) {Copia
encodeVal
è una funzione che il codice fuori catena chiama per aiutare a creare calldata che utilizzano 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 // The value isn't in the cache yet, add it4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));Copia
Nell'EVM si presume che tutta l'archiviazione non inizializzata contenga zeri. Quindi se cerchiamo la chiave per un valore assente, otteniaamo uno zero. In quel caso i byte che la codificano sono INTO_CACHE
(quindi sarà salvato nella cache la prossima volta), seguiti dal valore effettivo.
1 // If the key is <0x10, return it as a single byte2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));Copia
I byte singoli sono i più facili. Semplicemente, utilizziamo bytes.concat
(opens in a new tab) per trasformare un tipo bytes<n>
in un insieme di byte di qualsiasi lunghezza. Nonostante il nome, funziona bene quando fornito con un solo argomento.
1 // Two byte value, encoded as 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));Copia
Quando abbiamo una chiave inferiore a 163, possiamo esprimerla in due byte. Prima convertiamo _key
, un valore da 256 bit, in un valore da 16 bit, quindi utilizziamo il logical o aggiungiamo il numero di byte aggiuntivi al primo byte. Quindi abbiamo un valore bytes2
convertibile in bytes
.
1 // There is probably a clever way to do the following lines as a loop,2 // but it's a view function so I'm optimizing for programmer time and3 // simplicity.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 tuttoCopia
Gli altri valori (3 byte, 4 byte, ecc.) sono gestiti allo stesso modo, ma con dimensioni del campo differenti.
1 // If we get here, something is wrong.2 revert("Error in encodeVal, should not happen");Copia
Se arriviamo qui significa che abbiamo una chiave non inferiore a 16*25615. Ma cacheWrite
limita le chiavi quindi non possiamo arrivare nemmeno fino a 14*25616 (che avrebbe un primo byte di 0xFE, quindi apparirebbe così: DONT_CACHE
). Ma aggiungere un test nel caso in cui un programmatore futuro aggiunga un bug non ci costa molto.
1 } // encodeVal23} // CacheCopia
Testare la cache
Uno dei vantaggi di Foundry è che ti consente di scrivere i test in Solidity(opens in a new tab), semplificando la scrittura dei test unitari. I test per la classe Cache
sono qui(opens in a new tab). Poiché il codice del test è ripetitivo, come tendono a essere i test, questo articolo spiega soltanto le parti interessanti.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// Need to run `forge test -vv` for the console.8import "forge-std/console.sol";Copia
Questo è solo un modello standard necessario per utilizzare il pacchetto del test e console.log
.
1import "src/Cache.sol";Copia
Dobbiamo conoscere il contratto che stiamo testando.
1contract CacheTest is Test {2 Cache cache;34 function setUp() public {5 cache = new Cache();6 }Copia
La funzione setUp
viene chiamata prima di ogni test. In questo caso, creiamo semplicemente una nuova cache, così che i nostri test non si influenzeranno a vicenda.
1 function testCaching() public {Copia
I test sono funzioni i cui nomi iniziano per test
. Questa funzione verifica la funzionalità di base della cache, scrivendo i valori e rileggendoli.
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);Copia
Ecco come si esegue il test effettivo, utilizzando le funzioni assert...
(opens in a new tab). In questo caso verifichiamo che il valore scritto sia quello che leggiamo. Possiamo scartare il risultato di cache.cacheWrite
, poiché sappiamo che le chiavi della cache sono assegnate linearmente.
1 }2 } // testCaching345 // Cache the same value multiple times, ensure that the key stays6 // the same7 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 tuttoCopia
Prima scriviamo ogni valore due volte nella cache e ci assicuriamo che le chiavi siano uguali (a significare che la seconda scrittura non si è verificata realmente).
1 for(uint i=1; i<100; i+=3) {2 uint _key = cache.cacheWrite(i);3 assertEq(_key, i);4 }5 } // testRepeatCachingCopia
In teoria, potrebbe esserci un bug che non influenza le scritture consecutive nella cache. Quindi qui eseguiamo altre scritture non consecutive e visualizziamo i valori che non sono ancora riscritti.
1 // Read a uint from a memory buffer (to make sure we get back the parameters2 // we sent out)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)Copia
Legge una parola da 256 bit da un buffer bytes memory
. Questa funzione di utilità ci consente di verificare che riceviamo i risultati corretti, eseguendo la chiamata a una 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 }Copia
Yul non supporta le strutture di dati oltre uint256
, quindi quando fai riferimento a strutture di dati più sofisticate, come il buffer di memoria _bytes
, ne ottieni l'indirizzo. Solidity memorizza i valori di bytes memory
come una parola da 32 byte contenente la lunghezza, seguita dai byte effettivi, quindi per ottenere il numero di byte _start
, dobbiamo calcolare _bytes+32+_start
.
12 return tempUint;3 } // toUint25645 // Function signature for fourParams(), courtesy of6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Just some constant values to see we're getting the correct values back10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Mostra tuttoCopia
Alcune costanti necessarie per il test.
1 function testReadParam() public {Copia
Chiama fourParams()
, una funzione che utilizza readParams
per testare la possibilità di leggere correttamente i parametri.
1 address _cacheAddr = address(cache);2 bool _success;3 bytes memory _callInput;4 bytes memory _callOutput;Copia
Non possiamo utilizzare il normale meccanismo ABI per chiamare una funzione utilizzando la cache, quindi dobbiamo utilizzare il meccanismo di basso livello <address>.call()
(opens in a new tab). Tale meccanismo prende bytes memory
come input e lo restituisce (insieme a un valore Booleano) come output.
1 // First call, the cache is empty2 _callInput = bytes.concat(3 FOUR_PARAMS,Copia
È utile, per lo stesso contratto, supportare sie le funzioni nella cache (per le chiamate direttamente dalle transazioni) che le funzioni non nella cache (per le chiamate da altri contratti intelligenti). Per farlo, dobbiamo continuare ad affidarci al meccanismo di Solidity per chiamare la funzione corretta, invece di mettere tutto in una funzione di fallback
(opens in a new tab). Farlo semplifica la compositività. Un singolo byte basterebbe per identificare la funzione in gran parte dei casi, quindi stiamo sprecando tre byte (16*3=48 gas). Tuttavia, al momento della scrittura di questa guida, quei 48 di gas costano 0,07 centesimi, un costo ragionevole del codice più semplice e meno soggetto a bug.
1 // First value, add it to the cache2 cache.INTO_CACHE(),3 bytes32(VAL_A),Copia
Il primo valore: un flag che dice che è un valore completo che necessita di essere scritto nella cache, seguito dai 32 byte del valore. Gli altri tre valori sono simili, ma VAL_B
non è scritto nella cache e VAL_C
è sia il terzo che il quarto parametro.
1 .2 .3 .4 );5 (_success, _callOutput) = _cacheAddr.call(_callInput);Copia
Qui è dove chiamiamo effettivamente il contratto Cache
.
1 assertEq(_success, true);Copia
Ci aspettiamo che la chiamata riesca.
1 assertEq(cache.cacheRead(1), VAL_A);2 assertEq(cache.cacheRead(2), VAL_C);Copia
Iniziamo con una cache vuota e poi aggiungiamo VAL_A
, seguito da VAL_C
. Ci aspetteremmo che la prima abbia la chiave 1 e che la seconda abbia la chiave 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);
Il risultato comprende i quattro parametri. Qui verifichiamo che sia corretto.
1 // Second call, we can use the cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // First value in the Cache6 bytes1(0x01),Copia
Le chiavi della cache sotto 16 sono composte da un solo byte.
1 // Second value, don't add it to the cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Third and fourth values, same value6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamMostra tuttoCopia
I test dopo la chiamata sono identici a quelli dopo la prima chiamata.
1 function testEncodeVal() public {Copia
Questa funzione è simile a testReadParam
, ma, invece di scrivere i parametri esplicitamente, utilizziamo 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 tuttoCopia
Il solo test aggiuntivo in testEncodeVal()
consiste nel 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 // Test encodeVal when the key is more than a single byte2 // Maximum three bytes because filling the cache to four bytes takes3 // too long.4 function testEncodeValBig() public {5 // Put a number of values in the cache.6 // To keep things simple, use key n for value n.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }Mostra tuttoCopia
La funzione testEncodeVal
precedente scrive soltanto quattro valori nella cache, quindi la parte della funzione che si occupa dei valori a più byte(opens in a new tab) non è controllata. Ma quel codice è complicato e tende ad avere errori.
La prima parte di questa funzione è un ciclo che scrive tutti i valori da 1 a 0x1FFF nella cache in ordine, quindi potrai codificarli e sapere cosa sta succedendo.
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // One byte 0x0F8 cache.encodeVal(0x0010), // Two bytes 0x10109 cache.encodeVal(0x0100), // Two bytes 0x110010 cache.encodeVal(0x1000) // Three bytes 0x20100011 );Mostra tuttoCopia
Testa valori da uno, due e tre byte. Non testiamo oltre tali valori perché ci vorrebbe troppo tempo per scrivere abbastanza elementi dello stack (almeno 0x10000000, approssimativamente, un quarto di miliardo).
1 .2 .3 .4 .5 } // testEncodeValBig678 // Test what with an excessively small buffer we get a revert9 function testShortCalldata() public {Mostra tuttoCopia
Testiamo cosa si verifica nel caso anomalo in cui non ci siano abbastanza parametri.
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldataCopia
Poiché si ripristina, 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 tutto
Questa funzione ottiene quattro parametri perfettamente legittimi, ma la cache è vuota, quindi non non sono presenti valori da leggere.
1 .2 .3 .4 // Test what with an excessively long buffer everything works file5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // First call, the cache is empty12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // First value, add it to the cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Second value, add it to the cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Third value, add it to the cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Fourth value, add it to the cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // And another value for "good luck"28 bytes4(0x31112233)29 );Mostra tuttoCopia
Questa funzione invia cinque valori. Sappiamo che il quinto valore è ignorato perché non è un elemento della cache valido, ma avrebbe causato un ripristino se non fosse stato incluso.
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9Mostra tuttoCopia
Un esempio di applicazione
Scrivere test in Solidity va bene, ma alla fine una dapp deve poter elaborare le richieste dall'esterno della catena per essere utile. Questo articolo dimostra come utilizzare la memorizzazione nella cache in una dapp con WORM
, che sta per "Write Once, Read Many" (Scrivi una volta, leggi molte volte). Se una chiave non è ancora stata scritta, puoi scriverci un valore. Se la chiave è già scritta, ottieni un ripristino.
Il contratto
Questo è il contratto(opens in a new tab). Per lo più ripete ciò che abbiamo già fatto con Cache
e CacheTest
, quindi copriremo soltanto le parti interessanti.
1import "./Cache.sol";23contract WORM is Cache {Copia
Il metodo più facile per utilizzare Cache
è ereditarlo nel proprio contratto.
1 function writeEntryCached() external {2 uint[] memory params = _readParams(2);3 writeEntry(params[0], params[1]);4 } // writeEntryCachedCopia
Questa funzione è simile a fourParam
nel precedente CacheTest
. Poiché non seguiamo le specifiche ABI, è meglio non dichiarare alcun parametro nella funzione.
1 // Make it easier to call us2 // Function signature for writeEntryCached(), courtesy of3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;Copia
Il codice esterno che chiama writeEntryCached
dovrà creare manualmente i calldata, invece di utilizzare worm.writeEntryCached
, poiché non seguiamo le specifiche ABI. Avere questo valore costante ne semplifica la scrittura.
Nota che anche se definiamo WRITE_ENTRY_CACHED
come una variabile di stato, per leggerla esternamente è necessario utilizzare la sua funzione di ottenimento, worm.WRITE_ENTRY_CACHED()
.
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)Copia
La funzione Leggi è una view
, quindi non richiede una transazione, né costa gas. Di conseguenza, non vi è beneficio nell'usare la cache per il parametro. Con le funzioni view, è meglio utilizzare il meccanismo standard, che è più semplice.
Il codice di prova
Questo è il codice di prova per il contratto(opens in a new tab). Anche in questo caso ci occupiamo soltanto di ciò che ci interessa.
1 function testWReadWrite() public {2 worm.writeEntry(0xDEAD, 0x60A7);34 vm.expectRevert(bytes("entry already written"));5 worm.writeEntry(0xDEAD, 0xBEEF);Copia
Questo (vm.expectRevert
)(opens in a new tab) è il modo in cui specifichiamo in un test Foundry che la chiamata successiva dovrebbe non riuscire e il motivo segnalato per l'errore. Questo si applica quando utilizziamo la sintassi <contract>.<function name>()
, piuttosto che creare i calldata e chiamare il contratto utilizzando l'interfaccia di basso livello (<contract>.call()
, etc.).
1 function testReadWriteCached() public {2 uint cacheGoat = worm.cacheWrite(0x60A7);Copia
Qui utilizziamo il fatto che cacheWrite
restituisce la chiave della cache. Questo non è qualcosa che prevediamo di utilizzare in produzione, poiché cacheWrite
cambia lo stato, ed è quindi chiamabile soltanto durante una transazione. Le transazioni non hanno valori restituiti, se hanno risultati, questi dovrebbero essere emessi come eventi. Quindi, il valore restituito di cacheWrite
è accessibile soltanto dal codice sulla catena, che non necessita il salvataggio nella cache dei parametri.
1 (_success,) = address(worm).call(_callInput);Copia
Questo è il modo in cui diciamo a Solidity che mentre <contract address>.call()
ha due valori restituiti, ci importa soltanto del primo.
1 (_success,) = address(worm).call(_callInput);2 assertEq(_success, false);Copia
Poiché utilizziamo la funzione di basso livello <address>.call()
, non possiamo utilizzare vm.expectRevert()
e dobbiamo guardare al valore di successo booleano, ottenuto 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 tuttoCopia
Così, 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 in Solidity è il codice in JavaScript che puoi tagliare e incollare nella tua applicazione. Per scrivere quel codice, ho distribuito WORM a Optimism Goerli(opens in a new tab), la nuova rete di prova di Optimism(opens in a new tab). Si trova all'indirizzo 0xd34335b1d818cee54e3323d3246bd31d94e6a78a
(opens in a new tab).
Puoi visualizzare qui il codice in JavaScript per il client(opens in a new tab). Per utilizzarlo:
Clona la repository di git:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.gitInstalla i pacchetti necessari:
1cd javascript2yarnCopia il file di configurazione:
1cp .env.example .envModifica
.env
per la tua configurazione:Parametro Valore MNEMONIC La frase mnemonica per un account avente abbastanza ETH da pagare per una transazione. Puoi ottenere ETH gratuiti per la rete Goerli di Optimism qui(opens in a new tab). OPTIMISM_GOERLI_URL URL per Goerli di Optimism. L'endpoint pubblico, https://goerli.optimism.io
, è limitato ma sufficiente per ciò che ci occorre quiEsegui
index.js
.1node index.jsQuesto esempio di applicazione prima scrive una voce nel WORM, mostrando i calldata e un collegamento alla transazione su Etherscan. Poi rilegge quella voce e mostra la chiave che utilizza e i valori nella voce (valore, numero del blocco e autore).
Gran parte del client è una normale Dapp in JavaScript. Quindi, ancora, analizzeremo soltanto le parti interessanti.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Need a new key every time8 const key = await worm.encodeVal(Number(new Date()))
Un dato spazio è scrivibile soltanto una volta, quindi utilizziamo la marca oraria per assicurarci di non riutilizzarlo.
1const val = await worm.encodeVal("0x600D")23// Write an entry4const calldata = func + key.slice(2) + val.slice(2)
Gli ether si aspettano che i dati di chiamata siano una stringa esadecimale, 0x
, seguita da un numero pari di crifre esadecimali. Poiché sia key
che val
iniziano con 0x
, dobbiamo rimuovere queste 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. Invece, dobbiamo utilizzare un meccanismo di livello inferiore.
1 .2 .3 .4 // Read the entry just written5 const realKey = '0x' + key.slice(4) // remove the FF flag6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Mostra tutto
Per leggere le voci possiamo utilizzare il meccanismo normale. Non serve utilizzare il salvataggio nella cache del parametro con le funzioni view
.
Conclusioni
Il codice in questo articolo è una prova di concetto, lo scopo è rendere l'idea facile da comprendere. Per un sistema pronto alla produzione, potresti voler implementare delle funzionalità aggiuntive:
Gestisce i valori diversi da
uint256
. Ad esempio, stringhe.Invece di una cache globale, forse, avere una mappatura tra utenti e cache. Utenti differenti utilizzano valori differenti.
I valori utilizzati per gli indirizzi sono distinti da quelli utilizzati per altri scopi. Potrebbe avere senso avere una cache separata soltanto per gli indirizzi.
Al momento, le chiavi della cache si basano su un algoritmo "il primo che arriva riceve la chiave più piccola". I primi sedici valori sono inviabili come un singolo byte. I 4080 valori successivi sono inviabili come due byte. Il successivo milione approssimativo di valori è in tre byte, ecc. Un sistema di produzione dovrebbe mantenere dei contatori di utilizzo sulle voci della cache e riorganizzarli così che i sedici valori più comuni siano un byte, i successivi 4080 valori più comuni siano due byte, ecc.
Tuttavia, questa è un'operazione potenzialmente pericolosa. Immagina la seguente sequenza di eventi:
Noam Naive chiama
encodeVal
per codificare l'indirizzo a cui desidera inviare i token. Quell'indirizzo è uno dei primi utilizzati sull'applicazione, quindi il valore codificato è 0x06. Questa è una funzioneview
, non una transazione, quindi si trova tra Noam e il nodo che utilizza, e nessun altro ne è a conoscenzaOwen Owner esegue l'operazione di riordinamento della cache. In pochissimi utilizzano realmente quell'indirizzo, quindi è ora codificato come 0x201122. Un valore differente, 1018, è assegnato a 0x06.
Noam Naive invia i suoi token a 0x06. I token arrivano all'indirizzo
0x0000000000000000000000000de0b6b3a7640000
e, poiché nessuno conosce la chiave privata per quell'indirizzo, restano bloccati lì. Noam non è felice.
Esistono dei modi per risolvere questo problema e il problema correlato delle transazioni nel mempool durante il riordino della cache, ma devi esserne consapevole.
Qui, ho dimostrato il salvataggio nella cache con Optimism, perché ne sono un dipendente ed è il rollup che conosco meglio. Ma dovrebbe funzionare con qualsiasi rollup che addebiti un costo minimo per l'elaborazione interna, così che, in confronto, scrivere i dati della transazione a L1 sia la spesa maggiore.
Ultima modifica: @nhsz(opens in a new tab), 15 agosto 2023