Blockchain — o tehnologie inovatoare care promite să îmbunătățească multe domenii ale vieții umane. Aceasta transferă procesele și produsele reale în spațiul digital, asigurând rapiditate și fiabilitate în operațiunile financiare, reducând costurile acestora și permițând totodată crearea de aplicații DAPP moderne utilizând contracte inteligente în rețele descentralizate.
Având în vedere numeroasele avantaje și diversele domenii de aplicare ale blockchain-ului, poate părea ciudat că această tehnologie promițătoare nu a pătruns în toate industriile. Problema este că blockchain-urile descentralizate moderne îi lipsesc scalabilitatea. Ethereum procesează aproximativ 20 de tranzacții pe secundă, ceea ce nu este suficient pentru a satisface cerințele unui business dinamic și modern. În același timp, companiile care utilizează tehnologia blockchain nu se hotărăsc să renunțe la Ethereum din cauza protecției ridicate împotriva hacking-ului și a defectelor de rețea.
Pentru a asigura descentralizarea, securitatea și scalabilitatea în blockchain, abordând astfel Trilema Scalabilității, echipa de dezvoltatori a creat Plasma Cash — un lanț secundar format dintr-un contract inteligent și o rețea privată bazată pe Node.js, care își transmite periodic starea către lanțul principal (Ethereum).

Procesele cheie în Plasma Cash
1. Utilizatorul apelează funcția contractului inteligent `deposit`, transmițând suma în ETH pe care dorește să o depună în tokenul Plasma Cash. Funcția contractului inteligent creează tokenul și generează un eveniment despre aceasta.
2. Nodurile Plasma Cash, abonați la evenimentele contractului inteligent, primesc evenimentul de creare a depozitului și adaugă în pool tranzacția de creare a tokenului.
3. Periodic, nodurile speciale Plasma Cash preiau toate tranzacțiile din pool (până la 1 milion) și formează un bloc din acestea, calculând arborele Merkle și, prin urmare, hash-ul. Acest bloc este trimis altor noduri pentru verificare. Nodurile verifică dacă hash-ul Merkle este valid, dacă tranzacțiile sunt valide (de exemplu, dacă expeditorul tokenului este proprietarul acestuia). După verificarea blocului, nodul apelează funcția `submitBlock` a contractului inteligent, care salvează în lanțul principal numărul și hash-ul Merkle al blocului. Contractul inteligent generează un eveniment de adăugare reușită a blocului. Tranzacțiile sunt eliminate din pool.
4. Nodurile care primesc evenimentul de submit al blocului încep să aplice tranzacțiile care au fost adăugate în bloc.
5. La un moment dat, deținătorul (sau non-deținătorul) token-ului dorește să-l retragă din Plasma Cash. Pentru aceasta, el apelează funcția `startExit`, trimițând informațiile despre ultimele 2 tranzacții pentru token, care confirmă că el este, de fapt, deținătorul token-ului. Contractul inteligent, folosind hash-ul Merkle, verifică existența tranzacțiilor în blocuri și trimite token-ul pentru retragere, care va avea loc în două săptămâni.
6. Dacă operațiunea de retragere a token-ului a avut loc cu încălcări (token-ul a fost cheltuit după începerea procedurii de retragere sau token-ul era deja străin înainte de retragere), deținătorul token-ului poate contesta retragerea în termen de două săptămâni.

Confidențialitatea este atinsă prin două metode
1. Lanțul principal nu știe nimic despre tranzacțiile care sunt formate și trimise în interiorul lanțului adjunc. Informațiile despre cine a introdus și a retras ETH în/sau din Plasma Cash rămân publice.
2. Lanțul adjunc permite organizarea tranzacțiilor anonime, utilizând zk-SNARKs.
Stiva tehnologică
- NodeJS
- Redis
- Ethereum
- Solidity
Testare
În dezvoltarea Plasma Cash, am testat viteza de funcționare a sistemului și am obținut următoarele rezultate:
- până la 35,000 de tranzacții pe secundă sunt adăugate în pool;
- până la 1,000,000 de tranzacții pot fi stocate într-un bloc.
Testele au fost efectuate pe următoarele 3 servere:
1. Intel Core i7-6700 Quad-Core Skylake incl. NVMe SSD — 512 GB, 64 GB DDR4 RAM
Au fost ridicate 3 noduri de validare Plasma Cash.
2. AMD Ryzen 7 1700X Octa-Core „Summit Ridge” (Zen), SATA SSD — 500 GB, 64 GB DDR4 RAM
A fost ridicat un nod ETH pe testnet Ropsten.
Au fost ridicate 3 noduri de validare Plasma Cash.
3. Intel Core i9-9900K Octa-Core incl. NVMe SSD — 1 TB, 64 GB DDR4 RAM
A fost ridicat 1 nod de submit al Plasma Cash.
Au fost ridicate 3 noduri de validare Plasma Cash.
A fost rulat un test pentru adăugarea tranzacțiilor în rețeaua Plasma Cash.
În total: 10 noduri Plasma Cash într-o rețea privată.
Test 1
Există o limită de 1 milion de tranzacții într-un bloc. Prin urmare, 1 milion de tranzacții ajung în 2 blocuri (deoarece sistemul reușește să prindă o parte din tranzacții și să le submită în timp ce sunt trimise).

Starea inițială: ultimul bloc #7; în baza de date sunt păstrate 1 milion de tranzacții și token-uri.
00:00 — începerea scriptului de generare a tranzacțiilor
01:37 — au fost create 1 milion de tranzacții și a început trimiterea către nod
01:46 — nodul de submit a preluat 240k tranzacții din pool și formează blocul #8. De asemenea, observăm că în pool sunt adăugate 320k tranzacții în 10 secunde
01:58 — blocul #8 a fost semnat și trimis pentru validare
02:03 — blocul #8 a fost validat și a fost apelată funcția `submitBlock` a contractului inteligent cu hash-ul Merkle și numărul blocului
02:10 — s-a încheiat executarea scriptului demo, care a trimis 1 milion de tranzacții în 32 de secunde
02:33 — nodurile au început să primească informații că blocul #8 a fost adăugat în lanțul rădăcină și au început să execute 240k tranzacții
02:40 — din pool a fost eliminat 240k tranzacții, care sunt deja în blocul #8
02:56 — nodul de submit a luat din pool restul de 760k tranzacții și a început să calculeze hash-ul Merkle și să semneze blocul #9
03:20 — toate nodurile conțin 1 milion 240k tranzacții și token-uri
03:35 — blocul #9 a fost semnat și trimis pentru validare altor noduri
03:41 — a apărut o eroare de rețea
04:40 — a expirat timpul de așteptare pentru validarea blocului #9
04:54 — nodul de submit a luat din pool restul de 760k tranzacții și a început să calculeze hash-ul Merkle și să semneze blocul #9
05:32 — blocul #9 a fost semnat și trimis pentru validare altor noduri
05:53 — blocul #9 a fost validat și trimis în lanțul rădăcină
06:17 — nodurile au început să primească informații că blocul #9 a fost adăugat în lanțul rădăcină și au început să execute 760k tranzacții
06:47 — pool-ul s-a curățat de tranzacțiile care se află în blocul #9
09:06 — toate nodurile conțin 2 milioane de tranzacții și token-uri
Test 2
Există o limită de 350k pe bloc. Ca rezultat, avem 3 blocuri.

Starea inițială: ultimul bloc #9; în baza de date sunt salvate 2 milioane de tranzacții și token-uri
00:00 — script-ul de generare a tranzacțiilor este deja pornit
00:44 — au fost create 1 milion de tranzacții și a început trimiterea în nod
00:56 — nodul de submit a luat din pool 320k tranzacții și formează blocul #10. De asemenea, observăm că în pool se adaugă 320k tranzacții în 10 secunde
01:12 — blocul #10 a fost semnat și trimis altor noduri pentru validare
01:18 — s-a încheiat executarea scriptului demo, care a trimis 1 milion de tranzacții în 34 de secunde
01:20 — blocul #10 a fost validat și trimis în lanțul rădăcină
01:51 — toate nodurile au primit din lanțul rădăcină informații că blocul #10 a fost adăugat și încep să aplice 320k tranzacții
02:01 — pool-ul s-a curățat de 320k tranzacții, care au fost adăugate în blocul #10
02:15 — nodul de submit a luat din pool 350k tranzacții și formează blocul #11
02:34 — blocul #11 a fost semnat și trimis altor noduri pentru validare
02:51 — blocul #11 a fost validat și trimis în lanțul rădăcină
02:55 — ultimul nod a executat tranzacțiile din blocul #10
10:59 — a avut loc o întârziere semnificativă în executarea tranzacției cu trimiterea blocului #9, dar aceasta a fost realizată și toate nodurile au primit informații despre aceasta și au început să execute 350k tranzacții
11:05 — pool-ul s-a curățat de 320k tranzacții, care au fost adăugate în blocul #11
12:10 — toate nodurile conțin 1 milion 670k tranzacții și tokenuri
12:17 — nodul de trimitere a preluat din pool 330k tranzacții și formează blocul #12
12:32 — blocul #12 a fost semnat și trimis altor noduri pentru validare
12:39 — blocul #12 a fost validat și trimis în lanțul principal
13:44 — toate nodurile au primit informația din lanțul principal că blocul #12 a fost adăugat și încep să aplice 330k tranzacții
14:50 — toate nodurile conțin 2 milioane tranzacții și tokenuri
Test 3
Pe primele două servere, un nod de validare a fost înlocuit cu un nod de trimitere.

Starea inițială: ultimul bloc #84; în bază sunt salvate 0 tranzacții și tokenuri
00:00 — Au fost lansate 3 scripturi care generează și trimit câte 1 milion de tranzacții
01:38 — au fost create 1 milion de tranzacții și a început trimiterea către nodul de trimitere #3
01:50 — nodul de trimitere #3 a preluat din pool 330k tranzacții și formează blocul #85 (f21). De asemenea, vedem că în pool se adaugă 350k tranzacții în 10 secunde
01:53 — au fost create 1 milion de tranzacții și a început trimiterea către nodul de trimitere #1
01:50 — nodul de trimitere #3 a preluat din pool 330k tranzacții și formează blocul #85 (f21). De asemenea, vedem că în pool se adaugă 350k tranzacții în 10 secunde
02:01 — nodul de trimitere #1 a preluat din pool 250k tranzacții și formează blocul #85 (65e)
02:06 — blocul #85 (f21) a fost semnat și trimis altor noduri pentru validare
02:08 — a terminat de funcționat scriptul demo al serverului #3, care a trimis 1 milion de tranzacții în 30 de secunde
02:14 — blocul #85 (f21) a fost validat și trimis în lanțul principal
02:19 — blocul #85 (65e) a fost semnat și trimis altor noduri pentru validare
02:22 — au fost create 1 milion de tranzacții și a început trimiterea către nodul de trimitere #2
02:27 — blocul #85 (65e) a fost validat și trimis în lanțul principal
02:29 — nodul de trimitere #2 a preluat din pool 111855 tranzacții și formează blocul #85 (256).
02:36 — blocul #85 (256) a fost semnat și trimis altor noduri pentru validare
02:36 — a terminat de funcționat scriptul demo al serverului #1, care a trimis 1 milion de tranzacții în 42.5 secunde
02:38 — blocul #85 (256) a fost validat și trimis în lanțul principal
03:08 — a terminat de funcționat scriptul demo al serverului #2, care a trimis 1 milion de tranzacții în 47 secunde
03:38 — toate nodurile au primit informația din lanțul principal că blocurile #85 (f21), #86(65e), #87(256) au fost adăugate și încep să aplice 330k, 250k, 111855 tranzacții
03:49 — pool-ul s-a curățat de 330k, 250k, 111855 tranzacții, care au fost adăugate în blocurile #85 (f21), #86(65e), #87(256)
03:59 — submit node #1 a preluat din pool 888145 tranzacții și formează blocul #88 (214), submit node #2 a preluat din pool 750k tranzacții și formează blocul #88 (50a), submit node #3 a preluat din pool 670k tranzacții și formează blocul #88 (d3b)
04:44 — blocul #88 (d3b) a fost semnat și trimis altor noduri pentru validare
04:58 — blocul #88 (214) a fost semnat și trimis altor noduri pentru validare
05:11 — blocul #88 (50a) a fost semnat și trimis altor noduri pentru validare
05:11 — blocul #85 (d3b) a fost validat și trimis în lanțul rădăcină
05:36 — blocul #85 (214) a fost validat și trimis în lanțul rădăcină
05:43 — toate nodurile au primit din lanțul rădăcină informația că blocurile #88 (d3b), #89(214) au fost adăugate și încep să aplice 670k, 750k tranzacții
06:50 — din cauza pierderii conexiunii, blocul #85 (50a) nu a fost validat
06:55 — submit node #2 a preluat din pool 888145 tranzacții și formează blocul #90 (50a)
08:14 — blocul #90 (50a) a fost semnat și trimis altor noduri pentru validare
09:04 — blocul #90 (50a) a fost validat și trimis în lanțul rădăcină
11:23 — toate nodurile au primit din lanțul rădăcină informația că blocul #90 (50a) a fost adăugat și încep să aplice 888145 tranzacții. Între timp, serverul #3 a aplicat deja tranzacțiile din blocurile #88 (d3b), #89(214)
12:11 — toate pool-urile sunt goale
13:41 — toate nodurile serverului #3 conțin 3 milioane de tranzacții și token-uri
14:35 — toate nodurile serverului #1 conțin 3 milioane de tranzacții și token-uri
19:24 — toate nodurile serverului #2 conțin 3 milioane de tranzacții și token-uri
Obstacole
În timpul dezvoltării Plasma Cash ne-am confruntat cu următoarele probleme, pe care le-am rezolvat treptat:
1. Conflictul de interacțiune între diferitele funcții ale sistemului. De exemplu, funcția de adăugare a tranzacțiilor în pool bloca activitatea de submit și validare a blocurilor, și invers, ceea ce a dus la scăderea vitezei.
2. Nu a fost clar cum să trimitem un număr atât de mare de tranzacții și în același timp să minimizăm costurile de transfer de date.
3. Nu a fost clar cum și unde să stocăm datele pentru a obține rezultate de înaltă performanță.
4. Nu a fost clar cum să organizăm rețeaua între noduri, deoarece dimensiunea blocului cu 1 milion de tranzacții ocupă aproximativ 100 MB.
5. Funcționarea în modul pe un singur fir de execuție rupe conexiunea între noduri când au loc calcule lungi (de exemplu, construirea arborelui Merkle și calcularea hash-ului său).
Cum am reușit să facem față tuturor acestea?
Prima versiune a nodului Plasma Cash a reprezentat un fel de combinat care putea face totul simultan: primea tranzacții, trimitea și valida blocuri, oferea un API pentru accesul la date. Deoarece NodeJS este inițial un sistem pe un singur fir, funcția grea de calcul a arborelui Merkle bloca funcția de adăugare a tranzacției. Am constatat două variante de soluționare a acestei probleme:
1. Să lansăm mai multe procese NodeJS, fiecare executând funcții specifice.
2. Să folosim worker_threads și să mutăm execuția unei părți din cod în fire.
În final, am utilizat ambele variante simultan: am împărțit logic un nod în 3 părți care pot funcționa separat, dar în același timp sincronizat
1. Nodul de predare care primește tranzacții în pool și se ocupă de crearea blocurilor.
2. Nodul de validare care verifică validitatea nodurilor.
3. Nodul API - oferă API pentru accesul la date.
Fiecare nod poate fi conectat prin socket unix prin intermediul cli.
Operațiile grele, cum ar fi calculul arborelui Merkle, le-am externalizat într-un fir separat.
Astfel, am reușit să asigurăm funcționarea normală a tuturor funcțiilor Plasma Cash simultan și fără întreruperi.
Odată ce sistemul a început să funcționeze funcțional, am început să testăm viteza și, din păcate, am obținut rezultate nesatisfăcătoare: 5.000 de tranzacții pe secundă și până la 50.000 de tranzacții în bloc. A fost nevoie să investigăm ce a fost implementat greșit.
La început, am început să testăm mecanismul de comunicare cu Plasma Cash pentru a descoperi capacitatea maximă a sistemului. Anterior am menționat că nodul Plasma Cash oferă o interfață de socket unix. Inițial, aceasta era text. Obiectele json erau trimise folosind `JSON.parse()` și `JSON.stringify()`.
{
"action": "sendTransaction",
"payload":{
"prevHash": "0x8a88cc4217745fd0b4eb161f6923235da10593be66b841d47da86b9cd95d93e0",
"prevBlock": 41,
"tokenId": "57570139642005649136210751546585740989890521125187435281313126554130572876445",
"newOwner": "0x200eabe5b26e547446ae5821622892291632d4f4",
"type": "pay",
"data": "",
"signature": "0xd1107d0c6df15e01e168e631a386363c72206cb75b233f8f3cf883134854967e1cd9b3306cc5c0ce58f0a7397ae9b2487501b56695fe3a3c90ec0f61c7ea4a721c"
}
}
Am măsurat viteza de transmitere a acestor obiecte și am obținut aproximativ 130k pe secundă. Am încercat să înlocuim funcțiile standard pentru lucrul cu json, dar performanța nu a crescut. Probabil că motorul V8 este bine optimizat pentru aceste operații.
Gestionarea tranzacțiilor, token-urilor și blocurilor s-a realizat prin intermediul claselor. La crearea acestor clase, performanța a scăzut de două ori, ceea ce indică faptul că OOP nu este potrivit pentru noi. A fost necesar să rescriem totul folosind o abordare pur funcțională.
Scriere în baza de date
Inițial, pentru stocarea datelor, am ales Redis ca una dintre cele mai performante soluții care îndeplinește cerințele noastre: stocare key-value, lucru cu tabele hash, mulțimi. Am rulat redis-benchmark și am obținut ~80k operații pe secundă în modul 1 pipelining.
Pentru o performanță ridicată, am configurat Redis mai fin:
- Am configurat o conexiune prin socket unix.
- Am dezactivat salvarea stării pe disc (pentru fiabilitate, putem configura o replică și deja în Redis separat să facem salvarea pe disc).
În Redis, un pool este o tabelă hash, deoarece avem nevoie de posibilitatea de a obține toate tranzacțiile cu o singură interogare și de a șterge tranzacțiile pe rând. Am încercat să folosim o listă obișnuită, dar aceasta funcționează mai lent la descărcarea întregii liste.
Folosind biblioteca standard NodeJS pentru Redis, am obținut o performanță de 18k tranzacții pe secundă. Viteza a scăzut de 9 ori.
Deoarece benchmark-ul ne arăta că performanțele sunt evident de 5 ori mai mari, am început să optimizăm. Am schimbat biblioteca în ioredis și am obținut o performanță deja de 25k pe secundă. Am adăugat tranzacțiile pe rând, folosind comanda `hset`. Astfel, am generat multe interogări în Redis. A apărut ideea de a grupa tranzacțiile în pachete și de a le trimite cu o singură comandă `hmset`. Rezultatul — 32k pe secundă.
Din mai multe motive, pe care le vom descrie mai jos, lucrăm cu datele folosind `Buffer` și, așa cum s-a dovedit, dacă este convertit în text (`buffer.toString(‘hex’)`) înainte de scriere, se poate obține o performanță suplimentară. Astfel, am reușit să creștem viteza până la 35k pe secundă. În prezent, am decis să suspendăm optimizările suplimentare.
A trebuit să trecem la protocolul binar deoarece:
1. Sistemul calculează frecvent hash-uri, semnături etc., iar pentru aceasta are nevoie de date în `Buffer`.
2. La transmiterea între servicii, datele binare ocupă mai puțin decât textul. De exemplu, la trimiterea unui bloc cu 1 milion de tranzacții, datele în format text pot ocupa mai mult de 300 megabytes.
3. Conversia constantă a datelor afectează performanța.
Prin urmare, am folosit propriul nostru protocol binar de stocare și transmitere a datelor, dezvoltat pe baza excelentului biblioteci `binary-data`.
Ca rezultat, am obținut următoarele structuri de date:
— Transaction
```json
{
prevHash: BD.types.buffer(20),
prevBlock: BD.types.uint24le,
tokenId: BD.types.string(null),
type: BD.types.uint8,
newOwner: BD.types.buffer(20),
dataLength: BD.types.uint24le,
data: BD.types.buffer(({current}) => current.dataLength),
signature: BD.types.buffer(65),
hash: BD.types.buffer(32),
blockNumber: BD.types.uint24le,
timestamp: BD.types.uint48le,
}
```
— Token
```json
{
id: BD.types.string(null),
owner: BD.types.buffer(20),
block: BD.types.uint24le,
amount: BD.types.string(null),
}
```
— Block
```json
{
number: BD.types.uint24le,
merkleRootHash: BD.types.buffer(32),
signature: BD.types.buffer(65),
countTx: BD.types.uint24le,
transactions: BD.types.array(Transaction.Protocol, ({current}) => current.countTx),
timestamp: BD.types.uint48le,
}
```
Cu comenzile obișnuite `BD.encode(block, Protocol).slice();` și ` BD.decode(buffer, Protocol)` transformăm datele în `Buffer` pentru a fi salvate în Redis sau trimise către un alt nod și extrase înapoi.
Avem de asemenea 2 protocoale binare pentru transmiterea datelor între servicii:
— Protocol pentru interacțiunea cu Plasma Node prin socket unix
```json
{
type: BD.types.uint8,
messageId: BD.types.uint24le,
error: BD.types.uint8,
length: BD.types.uint24le,
payload: BD.types.buffer(({node}) => node.length)
}
```
unde:
- `type` — acțiunea care trebuie efectuată, de exemplu, 1 — sendTransaction, 2 — getTransaction;
- `payload` — datele care trebuie transmise funcției corespunzătoare;
- `messageId` — id-ul mesajului, pentru a putea identifica răspunsul.
— Protocol de interacțiune între noduri
```json
{
code: BD.types.uint8,
versionProtocol: BD.types.uint24le,
seq: BD.types.uint8,
countChunk: BD.types.uint24le,
chunkNumber: BD.types.uint24le,
length: BD.types.uint24le,
payload: BD.types.buffer(({node}) => node.length)
}
```
unde:
- `code` — codul mesajului, de exemplu 6 — PREPARE_NEW_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
- `versionProtocol` — versiunea protocolului, deoarece în rețea pot exista noduri cu versiuni diferite care pot funcționa diferit;
- `seq` — identificatorul mesajului;
- `countChunk` și `chunkNumber` necesare pentru fragmentarea mesajelor mari;
- `length` și `payload` lungimea și datele propriu-zise.
Deoarece am tipizat datele dinainte, sistemul final funcționează mult mai repede decât biblioteca `rlp` de la Ethereum. Din păcate, nu am reușit încă să renunțăm la ea, deoarece este necesară îmbunătățirea contractului inteligent, ceea ce intenționăm să facem în viitor.
Dacă am reușit să atingem viteza 35 000 transacții pe secundă, trebuie de asemenea să le procesăm într-un timp optim. Deoarece timpul de formare a unui bloc durează aproximativ 30 de secunde, trebuie să includem în bloc 1 000 000 transacții, ceea ce înseamnă transferul a mai mult de 100 MB de date.
Inițial am folosit biblioteca `ethereumjs-devp2p` pentru comunicarea între noduri, dar nu făcea față unui volum atât de mare de date. Ca urmare, am utilizat biblioteca `ws` și am configurat transferul de date binare prin websocket. Desigur, ne-am confruntat și cu probleme la transferul unor pachete mari de date, dar le-am împărțit în bucăți și acum aceste probleme nu mai există.
De asemenea, formarea arborelui Merkle și calculul hash-ului 1 000 000 transacțiilor necesită în jur de 10 secunde de calcul continuu. În acest timp, conexiunea cu toate nodurile riscă să se întrerupă. S-a decis să mutăm acest calcul într-un thread separat.
Concluzii:
De fapt, concluziile noastre nu sunt noi, dar din vreun motiv mulți specialiști le uită în timpul dezvoltării.
- Utilizarea Programării Funcționale în loc de Programarea Orientată pe Obiect crește performanța.
- Monolitul este mai slab decât arhitectura bazată pe servicii pentru un sistem performant pe NodeJS.
- Utilizarea `worker_threads` pentru calcule intense îmbunătățește reacția sistemului, în special atunci când se lucrează cu operațiuni i/o.
- socket-urile unix sunt mai stabile și mai rapide decât cererile http.
- Dacă trebuie să transferi rapid date mari prin rețea, este mai bine să folosești websocket-uri și să trimiti date binare, împărțite în bucăți, care pot fi retransmise dacă nu ajung, apoi reunite într-un singur mesaj.
Vă invităm să vizitați GitHub proiectul:
Articolul a fost scris în colaborare cu Alexandru Nașivan, dezvoltator senior .
Sursa: habr.com
