Vai al contenuto principale

Salva nella cache quanto vuoi

livello 2memorizzazione nella cachearchiviazione
Intermedio
Ori Pomerantz
15 settembre 2022
22 minuti letti minute read

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:

  1. Se il primo byte è 0xFF, prendi i successivi 32 byte come valore di parametro e scrivilo nella cache.

  2. Se il primo byte è 0xFE, prendi i successivi 32 byte come valore del parametro e non scriverli nella cache.

  3. 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 calldataChiave della cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipolazione della cache

La cache è implementata in Cache.sol(opens in a new tab). Analizziamolo riga per riga.

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;
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.

1
2 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 preserve
2 // 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 } // cacheRead
Copia

Legge un valore dalla cache.

1 // Write a value to the cache if it's not there already
2 // Only public to enable the test to work
3 function cacheWrite(uint _value) public returns (uint) {
4 // If the value is already in the cache, return the current key
5 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 can
2 // hold is 0x0D followed by 15 0xFF's. Se la lunghezza della cache è già quella
3 //large, fail.
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");
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 key
2 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 } // cacheWrite
Copia

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;
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");
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 } // _calldataVal
3
4
5 // Read a single parameter from the calldata, starting at _fromByte
6 function _readParam(uint _fromByte) internal
7 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 rest
2 uint8 _firstByte;
3
4 _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.

1
2 // Read the value, but do not write it to the cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Read the value, and write it to the cache
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // If we got here it means that we need to read from the cache
14
15 // Number of extra bytes to read
16 uint8 _extraBytes = _firstByte / 16;
Mostra tutto
Copia

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);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // Read n parameters (functions know how many parameters they expect)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Mostra tutto
Copia

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 read
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parameters start at byte 4, before that it's the function signature
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Mostra tutto
Copia

Leggi i parametri finché non ottieni il numero desiderato. Se andiamo oltre la fine dei calldata, _readParams ripristinerà la chiamata.

1
2 return(params);
3 } // readParams
4
5 // For testing _readParams, test reading four parameters
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
Mostra tutto
Copia

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];
2
3 // The value isn't in the cache yet, add it
4 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 byte
2 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 0x1vvv
2 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 and
3 // simplicity.
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)));
Mostra tutto
Copia

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 } // encodeVal
2
3} // Cache
Copia

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: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// 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;
3
4 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 }
4
5 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 } // testCaching
3
4
5 // Cache the same value multiple times, ensure that the key stays
6 // the same
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 }
Mostra tutto
Copia

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 } // testRepeatCaching
Copia

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 parameters
2 // we sent out)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 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;
4
5 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.

1
2 return tempUint;
3 } // toUint256
4
5 // Function signature for fourParams(), courtesy of
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Just some constant values to see we're getting the correct values back
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
Mostra tutto
Copia

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 empty
2 _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 cache
2 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 cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // First value in the Cache
6 bytes1(0x01),
Copia

Le chiavi della cache sotto 16 sono composte da un solo byte.

1 // Second value, don't add it to the cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Third and fourth values, same value
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
Mostra tutto
Copia

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 } // testEncodeVal
Mostra tutto
Copia

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 byte
2 // Maximum three bytes because filling the cache to four bytes takes
3 // 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 tutto
Copia

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 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // One byte 0x0F
8 cache.encodeVal(0x0010), // Two bytes 0x1010
9 cache.encodeVal(0x0100), // Two bytes 0x1100
10 cache.encodeVal(0x1000) // Three bytes 0x201000
11 );
Mostra tutto
Copia

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 } // testEncodeValBig
6
7
8 // Test what with an excessively small buffer we get a revert
9 function testShortCalldata() public {
Mostra tutto
Copia

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 } // testShortCalldata
Copia

Poiché si ripristina, dovremmo ottenere false.

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 );
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 file
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // First call, the cache is empty
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // First value, add it to the cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Second value, add it to the cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Third value, add it to the cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Fourth value, add it to the cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // And another value for "good luck"
28 bytes4(0x31112233)
29 );
Mostra tutto
Copia

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 } // testLongCalldata
7
8} // CacheTest
9
Mostra tutto
Copia

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";
2
3contract 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 } // writeEntryCached
Copia

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 us
2 // Function signature for writeEntryCached(), courtesy of
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 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 view
2 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);
3
4 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);
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);
Mostra tutto
Copia

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:

  1. Clona la repository di git:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. Installa i pacchetti necessari:

    1cd javascript
    2yarn
  3. Copia il file di configurazione:

    1cp .env.example .env
  4. Modifica .env per la tua configurazione:

    ParametroValore
    MNEMONICLa 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_URLURL per Goerli di Optimism. L'endpoint pubblico, https://goerli.optimism.io, è limitato ma sufficiente per ciò che ci occorre qui
  5. Esegui index.js.

    1node index.js

    Questo 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()
6
7 // Need a new key every time
8 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")
2
3// Write an entry
4const 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 = calldata
3
4sentTx = 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 written
5 const realKey = '0x' + key.slice(4) // remove the FF flag
6 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:

    1. 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 funzione view, non una transazione, quindi si trova tra Noam e il nodo che utilizza, e nessun altro ne è a conoscenza

    2. Owen 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.

    3. 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: @Herbie_23(opens in a new tab), 15 novembre 2023

Questo tutorial è stato utile?