Teste público: solução para privacidade e escalabilidade no Ethereum

Blockchain — uma tecnologia inovadora que promete melhorar muitas áreas da vida humana. Ela traz processos e produtos reais para o espaço digital, assegura a rapidez e confiabilidade das operações financeiras, reduz seus custos e permite ainda criar aplicativos DAPP modernos utilizando contratos inteligentes em redes descentralizadas.

Considerando as inúmeras vantagens e diversas áreas de aplicação do blockchain, pode parecer estranho que essa tecnologia promissora ainda não tenha se infiltrado em todos os setores. O problema é que os blockchains descentralizados modernos carecem de escalabilidade. O Ethereum processa cerca de 20 transações por segundo, o que não é suficiente para atender à demanda dos negócios dinâmicos atuais. Ao mesmo tempo, as empresas que utilizam a tecnologia blockchain hesitam em abandonar o Ethereum devido ao seu alto nível de proteção contra invasões e falhas de rede.

Para garantir descentralização, segurança e escalabilidade no blockchain, resolvendo assim a Trilogia da Escalabilidade, a equipe de desenvolvedores Opporty criou o Plasma Cash — uma cadeia lateral composta por um contrato inteligente e uma rede privada baseada em Node.js, que periodicamente transmite seu estado para a cadeia raiz (Ethereum).

Teste público: solução para privacidade e escalabilidade no Ethereum

Processos-chave no Plasma Cash

1. O usuário chama a função do contrato inteligente `deposit`, passando o valor em ETH que deseja colocar no token Plasma Cash. A função do contrato inteligente cria o token e gera um evento sobre isso.

2. Os nós do Plasma Cash, inscritos nos eventos do contrato inteligente, recebem o evento de criação do depósito e adicionam a transação de criação do token ao pool.

3. Periodicamente, nós especiais do Plasma Cash pegam todas as transações do pool (até 1 milhão) e formam um bloco a partir delas, calculam a árvore de Merkle e, consequentemente, o hash. Este bloco é enviado para outros nós para verificação. Os nós checam se o hash de Merkle é válido, se as transações são válidas (por exemplo, se o remetente do token é seu proprietário). Após a verificação do bloco, o nó chama a função `submitBlock` do contrato inteligente, que salva na cadeia raiz o número e o hash de Merkle do bloco. O contrato inteligente gera um evento sobre a adição bem-sucedida do bloco. As transações são removidas do pool.

4. Os nós que receberam o evento de submissão do bloco começam a aplicar as transações que foram adicionadas ao bloco.

5. Em algum momento, o proprietário (ou não proprietário) do token deseja retirá-lo do Plasma Cash. Para isso, ele invoca a função `startExit`, passando as informações das últimas 2 transações do token, que confirmam que ele é o proprietário do token. O contrato inteligente, usando o hash Merkle, verifica a presença das transações nos blocos e envia o token para saída, que ocorrerá em duas semanas.

6. Se a operação de retirada do token ocorreu com violações (o token foi gasto após o início do procedimento de retirada ou o token já pertencia a outra pessoa antes da retirada), o proprietário do token pode contestar a saída dentro de duas semanas.

Teste público: solução para privacidade e escalabilidade no Ethereum

A privacidade é alcançada de duas maneiras

1. A cadeia raiz não sabe nada sobre as transações que são formadas e enviadas dentro da cadeia filha. A informação sobre quem depositou e retirou ETH no Plasma Cash permanece pública.

2. A cadeia filha permite organizar transações anônimas usando zk-SNARKs.

Pilha tecnológica

  • NodeJS
  • Redis
  • Etherium
  • Soild

Testes

Desenvolvendo o Plasma Cash, testamos a velocidade do sistema e obtivemos os seguintes resultados:

  • até 35.000 transações por segundo são adicionadas ao pool;
  • até 1.000.000 transações podem ser armazenadas em um bloco.

Os testes foram realizados em 3 servidores seguintes:

1. Intel Core i7-6700 Quad-Core Skylake incl. SSD NVMe — 512 GB, 64 GB DDR4 RAM
Foram levantados 3 nós validador de Plasma Cash.

2. AMD Ryzen 7 1700X Octa-Core "Summit Ridge" (Zen), SSD SATA — 500 GB, 64 GB DDR4 RAM
Foi levantado um nó de ETH na testnet Ropsten.
Foram levantados 3 nós validador de Plasma Cash.

3. Intel Core i9-9900K Octa-Core incl. SSD NVMe — 1 TB, 64 GB DDR4 RAM
Foi levantado 1 nó de submissão de Plasma Cash.
Foram levantados 3 nós validador de Plasma Cash.
Executou-se um teste de adição de transações na rede Plasma Cash.

No total: 10 nós de Plasma Cash em uma rede privada.

Teste 1

Há um limite de 1 milhão de transações por bloco. Portanto, 1 milhão de transações vão para 2 blocos (já que o sistema consegue pegar uma parte das transações e submeter enquanto elas estão sendo enviadas).

Por que no hosting comum a administração do PrestaShop é lenta, e no seu não é?

Estado inicial: último bloco #7; 1 milhão de transações e tokens armazenados na base.

00:00 — início do script de geração de transações
01:37 — 1 milhão de transações criadas e envio iniciado para o nó
01:46 — o nó de submissão pegou 240 mil transações do pool e está formando o bloco #8. Também vemos que 320 mil transações foram adicionadas ao pool em 10 segundos
01:58 — bloco #8 assinado e enviado para validação
02:03 — o bloco #8 foi validado e a função `submitBlock` do contrato inteligente foi chamada com o hash Merkle e o número do bloco
02:10 — o script de demonstração terminou, que enviou 1 milhão de transações em 32 segundos
02:33 — os nós começaram a receber informações de que o bloco #8 foi adicionado à cadeia raiz e começaram a realizar 240 mil transações
02:40 — foram removidas 240 mil transações do pool, que já estão no bloco #8
02:56 — o nó de submissão pegou do pool as restantes 760 mil transações e começou a calcular o hash Merkle e a assinar o bloco #9
03:20 — todos os nós contêm 1 milhão e 240 mil transações e tokens
03:35 — o bloco #9 foi assinado e enviado para validação em outros nós
03:41 — ocorreu um erro de rede
04:40 — o tempo limite expirou ao aguardar a validação do bloco #9
04:54 — o nó de submissão pegou do pool as restantes 760 mil transações e começou a calcular o hash Merkle e a assinar o bloco #9
05:32 — o bloco #9 foi assinado e enviado para validação em outros nós
05:53 — o bloco #9 foi validado e enviado à cadeia raiz
06:17 — os nós começaram a receber informações de que o bloco #9 foi adicionado à cadeia raiz e começaram a executar 760 mil transações
06:47 — o pool foi limpo das transações que estão no bloco #9
09:06 — todos os nós contêm 2 milhões de transações e tokens

Teste 2

Há um limite de 350 mil por bloco. Como resultado, temos 3 blocos.

Por que no hosting comum a administração do PrestaShop é lenta, e no seu não é?

Estado inicial: último bloco #9; no banco estão salvas 2 milhões de transações e tokens

00:00 — o script de geração de transações já foi iniciado
00:44 — 1 milhão de transações foi criado e o envio para o nó foi iniciado
00:56 — o nó de submissão pegou do pool 320 mil transações e está formando o bloco #10. Também vemos que 320 mil transações estão sendo adicionadas ao pool em 10 segundos
01:12 — o bloco #10 foi assinado e enviado para outros nós para validação
01:18 — o script de demonstração terminou, que enviou 1 milhão de transações em 34 segundos
01:20 — o bloco #10 foi validado e enviado à cadeia raiz
01:51 — todos os nós receberam informações da cadeia raiz de que o bloco #10 foi adicionado e começam a aplicar 320 mil transações
02:01 — o pool foi limpo de 320 mil transações, que foram adicionadas ao bloco #10
02:15 — o nó de submissão pegou do pool 350 mil transações e está formando o bloco #11
02:34 — o bloco #11 foi assinado e enviado para outros nós para validação
02:51 — o bloco #11 foi validado e enviado à cadeia raiz
02:55 — o último nó executou transações do bloco #10
10:59 — a transação com o envio do bloco #9 levou muito tempo na cadeia raiz, mas foi concluída e todos os nós receberam essa informação e começaram a processar 350 mil transações
11:05 — o pool foi limpo de 320 mil transações, que foram adicionadas ao bloco #11
12:10 — todos os nós contêm 1 milhão e 670 mil transações e tokens
12:17 — o nó de envio extraiu 330 mil transações do pool e está formando o bloco #12
12:32 — o bloco #12 foi assinado e enviado para outros nós para validação
12:39 — o bloco #12 foi validado e enviado para a cadeia raiz
13:44 — todos os nós receberam da cadeia raiz a informação de que o bloco #12 foi adicionado e começam a aplicar 330 mil transações
14:50 — todos os nós contêm 2 milhões de transações e tokens

Teste 3

No primeiro e no segundo servidores, um nó validador foi substituído por um nó de envio.

Por que no hosting comum a administração do PrestaShop é lenta, e no seu não é?

Estado inicial: último bloco #84; no banco de dados estão salvas 0 transações e tokens

00:00 — Iniciados 3 scripts que geram e enviam 1 milhão de transações cada
01:38 — 1 milhão de transações criadas e o envio para o nó de envio #3 foi iniciado
01:50 — o nó de envio #3 extraiu 330 mil transações do pool e está formando o bloco #85 (f21). Também podemos ver que 350 mil transações são adicionadas ao pool em 10 segundos
01:53 — 1 milhão de transações criadas e o envio para o nó de envio #1 foi iniciado
01:50 — o nó de envio #3 extraiu 330 mil transações do pool e está formando o bloco #85 (f21). Também podemos ver que 350 mil transações são adicionadas ao pool em 10 segundos
02:01 — o nó de envio #1 extraiu 250 mil transações do pool e está formando o bloco #85 (65e)
02:06 — o bloco #85 (f21) foi assinado e enviado para outros nós para validação
02:08 — o script de demonstração do servidor #3 terminou, que enviou 1 milhão de transações em 30 segundos
02:14 — o bloco #85 (f21) foi validado e enviado para a cadeia raiz
02:19 — o bloco #85 (65e) foi assinado e enviado para outros nós para validação
02:22 — 1 milhão de transações criadas e o envio para o nó de envio #2 foi iniciado
02:27 — o bloco #85 (65e) foi validado e enviado para a cadeia raiz
02:29 — o nó de envio #2 extraiu 111855 transações do pool e está formando o bloco #85 (256).
02:36 — o bloco #85 (256) foi assinado e enviado para outros nós para validação
02:36 — o script de demonstração do servidor #1 terminou, que enviou 1 milhão de transações em 42.5 segundos
02:38 — o bloco #85 (256) foi validado e enviado para a cadeia raiz
03:08 — o script do servidor #2 terminou, que enviou 1 milhão de transações em 47 segundos
03:38 — todos os nós receberam da cadeia raiz a informação de que os blocos #85 (f21), #86(65e), #87(256) foram adicionados e começam a aplicar 330 mil, 250 mil, 111855 transações
03:49 — o pool foi limpo de 330k, 250k, 111855 transações, que foram adicionadas aos blocos #85 (f21), #86(65e), #87(256)
03:59 — o submit do nó #1 retirou 888145 transações do pool e está formando o bloco #88 (214), o submit do nó #2 retirou 750k transações do pool e está formando o bloco #88 (50a), o submit do nó #3 retirou 670k transações do pool e está formando o bloco #88 (d3b)
04:44 — o bloco #88 (d3b) foi assinado e enviado a outros nós para validação
04:58 — o bloco #88 (214) foi assinado e enviado a outros nós para validação
05:11 — o bloco #88 (50a) foi assinado e enviado a outros nós para validação
05:11 — o bloco #85 (d3b) foi validado e enviado à cadeia principal
05:36 — o bloco #85 (214) foi validado e enviado à cadeia principal
05:43 — todos os nós receberam da cadeia principal a informação de que os blocos #88 (d3b), #89(214) foram adicionados e começam a aplicar 670k, 750k transações
06:50 — devido à perda de conexão, o bloco #85 (50a) não foi validado
06:55 — o submit do nó #2 retirou 888145 transações do pool e está formando o bloco #90 (50a)
08:14 — o bloco #90 (50a) foi assinado e enviado a outros nós para validação
09:04 — o bloco #90 (50a) foi validado e enviado à cadeia principal
11:23 — todos os nós receberam da cadeia principal a informação de que o bloco #90 (50a) foi adicionado, e começam a aplicar 888145 transações. Nesse meio tempo, o servidor #3 já havia aplicado transações dos blocos #88 (d3b), #89(214)
12:11 — todos os pools estão vazios
13:41 — todos os nós do servidor #3 contêm 3 milhões de transações e tokens
14:35 — todos os nós do servidor #1 contêm 3 milhões de transações e tokens
19:24 — todos os nós do servidor #2 contêm 3 milhões de transações e tokens

Obstáculos

Durante o desenvolvimento do Plasma Cash, encontramos os seguintes problemas, que estamos resolvendo gradualmente:

1. Conflito de interação entre diferentes funções do sistema. Por exemplo, a função de adicionar transações ao pool bloqueava o funcionamento do submit e da validação dos blocos, e vice-versa, o que levava à queda da velocidade.

2. Não estava claro como enviar um grande número de transações e, ao mesmo tempo, minimizar os custos de transferência de dados.

3. Não estava claro como e onde armazenar os dados para alcançar resultados altos.

4. Não estava claro como organizar a rede entre os nós, uma vez que o tamanho do bloco com 1 milhão de transações ocupa cerca de 100 Mb.

5. Trabalhar em modo monothread interrompe a conexão entre os nós durante longos cálculos (por exemplo, construção da árvore de Merkle e cálculo de seu hash).

Como lidamos com tudo isso?

A primeira versão do nó Plasma Cash era uma espécie de combinação que podia fazer tudo ao mesmo tempo: aceitar transações, submeter e validar blocos, e fornecia uma API para acesso aos dados. Como o NodeJS é originalmente de thread única, uma pesada função de cálculo da árvore Merkle bloqueava a função de adição de transações. Nós víamos duas opções para resolver esse problema:

1. Executar vários processos NodeJS, cada um realizando funções específicas.

2. Utilizar worker_threads e mover parte do código para threads.

No final, usamos ambas as opções simultaneamente: dividimos logicamente um nó em 3 partes, que podem funcionar separadamente, mas ao mesmo tempo de maneira síncrona.

1. Um nó de submissão que aceita transações em um pool e se encarrega da criação de blocos.

2. Um nó de validação que verifica a validade dos nós.

3. Um nó de API — fornece uma API para acesso aos dados.

Além disso, cada nó pode ser conectado através de um socket unix utilizando cli.

Operações pesadas, como o cálculo da árvore Merkle, foram movidas para uma thread separada.

Dessa forma, conseguimos fazer todas as funções do Plasma Cash funcionarem simultaneamente e sem falhas.

Assim que o sistema começou a operar funcionalmente, começamos a testar a velocidade e, infelizmente, obtivemos resultados insatisfatórios: 5.000 transações por segundo e até 50.000 transações por bloco. Tivemos que investigar o que foi implementado incorretamente.

Para começar, começamos a testar o mecanismo de comunicação com o Plasma Cash, para saber a capacidade máxima do sistema. Anteriormente dissemos que o nó Plasma Cash fornece uma interface de socket unix. Originalmente, era textual. Objetos json eram transmitidos usando `JSON.parse()` e `JSON.stringify()`.

{"action": "sendTransaction","payload":{"prevHash": "0x8a88cc4217745fd0b4eb161f6923235da10593be66b841d47da86b9cd95d93e0","prevBlock": 41,"tokenId": "57570139642005649136210751546585740989890521125187435281313126554130572876445","newOwner": "0x200eabe5b26e547446ae5821622892291632d4f4","type": "pay","data": "","signature": "0xd1107d0c6df15e01e168e631a386363c72206cb75b233f8f3cf883134854967e1cd9b3306cc5c0ce58f0a7397ae9b2487501b56695fe3a3c90ec0f61c7ea4a721c"}}

Nós medimos a velocidade de envio desses objetos e obtivemos cerca de 130k por segundo. Tentamos substituir as funções padrão de trabalho com json, mas a performance não melhorou. Deve ser que o motor V8 está bem otimizado para essas operações.

O trabalho com transações, tokens e blocos foi realizado através de classes. Ao criar essas classes, o desempenho caiu pela metade, o que indica que a OOP não é adequada para nós. Tivemos que reescrever tudo usando uma abordagem puramente funcional.

Gravação no banco de dados

Inicialmente, escolhemos o Redis para armazenamento de dados como uma das soluções mais eficientes que atende nossas demandas: um armazenamento key-value, com suporte a hash-tabelas e conjuntos. Rodamos o redis-benchmark e obtivemos cerca de 80 mil operações por segundo em modo de 1 pipelining.

Para alta performance, configuramos o Redis com mais detalhes:

  • Estabelecemos a conexão via unix socket.
  • Desativamos o salvamento de dados no disco (para maior confiabilidade, é possível configurar uma réplica e realizar o salvamento em um Redis separado).

No Redis, um pool é uma hash-table, pois precisamos da capacidade de obter todas as transações em uma única consulta e remover transações individualmente. Tentamos usar uma lista comum, mas esta funcionou mais lentamente ao descarregar a lista inteira.

Ao usar a biblioteca padrão do NodeJS para Redis, obtivemos uma performance de 18 mil transações por segundo. A velocidade caiu em 9 vezes.

Como o benchmark mostrou que nossas capacidades eram claramente 5 vezes maiores, começamos a otimizar. Mudamos a biblioteca para ioredis e conseguimos 25 mil por segundo. Adicionamos transações individualmente, utilizando o comando `hset`. Assim, geramos muitas consultas no Redis. Surgiu a ideia de agrupar transações em lotes e enviá-las com um único comando `hmset`. O resultado — 32 mil por segundo.

Por várias razões que descreveremos abaixo, trabalhamos com os dados usando `Buffer` e, como se viu, se convertê-lo para texto (`buffer.toString('hex')`) antes da gravação, podemos obter desempenho adicional. Dessa forma, conseguimos aumentar a velocidade para 35 mil por segundo. No momento, decidimos suspender a otimização adicional.

Tivemos que mudar para um protocolo binário porque:

1. O sistema frequentemente calcula hashes, assinaturas, etc., e para isso ele precisa de dados em `Buffer`.

2. Ao transmitir entre serviços, dados binários ocupam menos espaço do que texto. Por exemplo, ao enviar um bloco com 1 milhão de transações, os dados em texto podem ocupar mais de 300 megabytes.

3. A conversão constante de dados impacta o desempenho.

Portanto, baseamos nosso protocolo binário próprio de armazenamento e transmissão de dados, desenvolvido com a maravilhosa biblioteca `binary-data`.

Como resultado, obtivemos as seguintes estruturas de dados:

— Transação

  ```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),
  }
  ```

— Bloco

  ```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,
  }
  ```

Usando os comandos `BD.encode(block, Protocol).slice();` e ` BD.decode(buffer, Protocol)`, transformamos os dados em `Buffer` para armazenamento no Redis ou envio para outro nó e extraímos os dados de volta.

Também temos 2 protocolos binários para transmissão de dados entre serviços:

— Protocolo para interação com o Plasma Node através de 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)
  }
  ```

onde:

  • `type` — ação que deve ser executada, por exemplo, 1 — sendTransaction, 2 — getTransaction;
  • `payload` — dados que devem ser enviados para a função correspondente;
  • `messageId` — id da mensagem, para que seja possível identificar a resposta.

— Protocolo de interação entre nós

  ```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)
  }
  ```

onde:

  • `code` — código da mensagem, por exemplo, 6 — PREPARE_NEW_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
  • `versionProtocol` — versão do protocolo, já que na rede podem haver nós com diferentes versões e eles podem funcionar de maneiras diferentes;
  • `seq` — identificador da mensagem;
  • `countChunk` e `chunkNumber` necessários para fragmentar mensagens grandes;
  • `length` e `payload` tamanho e os próprios dados.

Como tipificamos os dados antecipadamente, o sistema final funciona muito mais rápido do que a biblioteca `rlp` da Ethereum. Infelizmente, ainda não conseguimos nos livrar dela, pois é necessário aprimorar o contrato inteligente, o que planejamos fazer no futuro.

Se conseguimos alcançar a velocidade 35 000 transações por segundo, também precisamos processá-las em um tempo otimizado. Como o tempo aproximado para a formação de um bloco é de 30 segundos, precisamos incluir no bloco 1 000 000 transações, o que significa o envio de mais de 100 MB de dados.

Inicialmente, utilizamos a biblioteca `ethereumjs-devp2p` para a comunicação entre nós, mas ela não conseguiu lidar com essa quantidade de dados. Como resultado, usamos a biblioteca `ws` e configuramos o envio de dados binários por meio de websocket. Claro que também enfrentamos problemas ao enviar grandes pacotes de dados, mas dividimos esses pacotes em chunks e agora esses problemas não existem mais.

A formação da árvore de Merkle e o cálculo do hash 1 000 000 das transações leva cerca de 10 segundos de cálculo contínuo. Nesse tempo, a conexão com todos os nós acaba se desconectando. Decidimos mover esse cálculo para uma thread separada.

Conclusões:

Na verdade, nossas conclusões não são novas, mas por algum motivo muitos especialistas as esquecem ao desenvolver.

  • O uso de Programação Funcional em vez de Programação Orientada a Objetos aumenta a performance.
  • Um monolito é pior do que uma arquitetura de serviços para um sistema produtivo em NodeJS.
  • O uso de `worker_threads` para cálculos pesados melhora a responsividade do sistema, especialmente ao lidar com operações de I/O.
  • socket unix é mais estável e mais rápido do que requisições http.
  • Se você precisa transmitir grandes dados rapidamente pela rede, é melhor usar websockets e enviar dados binários, divididos em chunks, que podem ser retransmitidos se não chegarem, e depois reunidos em uma única mensagem.

Convidamos você a visitar GitHub do projeto: https://github.com/opporty-com/Plasma-Cash/tree/new-version

O artigo foi escrito em coautoria com Alexander Nashivan, desenvolvedor sênior Clever Solution Inc.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster