Prueba pública: una solución para la privacidad y la escalabilidad en Ethereum

Bloccain es una tecnología innovadora que promete mejorar muchas áreas de la vida humana. Transfiere procesos y productos reales al espacio digital, garantiza la velocidad y confiabilidad de las transacciones financieras, reduce su costo y también le permite crear aplicaciones DAPP modernas utilizando contratos inteligentes en redes descentralizadas.

Teniendo en cuenta los numerosos beneficios y las diversas aplicaciones de blockchain, puede parecer sorprendente que esta prometedora tecnología aún no haya llegado a todas las industrias. El problema es que las cadenas de bloques descentralizadas modernas carecen de escalabilidad. Ethereum procesa alrededor de 20 transacciones por segundo, lo que no es suficiente para satisfacer las necesidades de las dinámicas empresas actuales. Al mismo tiempo, las empresas que utilizan la tecnología blockchain dudan en abandonar Ethereum debido a su alto grado de protección contra piratería y fallas de red.

Para garantizar la descentralización, la seguridad y la escalabilidad en blockchain, resolviendo así el Trilema de la Escalabilidad, el equipo de desarrollo Opporty creó Plasma Cash, una cadena subsidiaria que consta de un contrato inteligente y una red privada basada en Node.js, que transfiere periódicamente su estado a la cadena raíz (Ethereum).

Prueba pública: una solución para la privacidad y la escalabilidad en Ethereum

Procesos clave en Plasma Cash

1. El usuario llama a la función de contrato inteligente "depósito" y le pasa la cantidad de ETH que desea depositar en el token Plasma Cash. La función de contrato inteligente crea un token y genera un evento al respecto.

2. Los nodos de Plasma Cash suscritos a eventos de contratos inteligentes reciben un evento sobre la creación de un depósito y agregan una transacción sobre la creación de un token al grupo.

3. Periódicamente, nodos especiales de Plasma Cash toman todas las transacciones del grupo (hasta 1 millón) y forman un bloque a partir de ellas, calculan el árbol Merkle y, en consecuencia, el hash. Este bloque se envía a otros nodos para su verificación. Los nodos verifican si el hash de Merkle es válido y si las transacciones son válidas (por ejemplo, si el remitente del token es su propietario). Después de verificar el bloque, el nodo llama a la función `submitBlock` del contrato inteligente, que guarda el número de bloque y el hash de Merkle en la cadena de borde. El contrato inteligente genera un evento que indica la adición exitosa de un bloque. Las transacciones se eliminan del grupo.

4. Los nodos que reciben el evento de envío del bloque comienzan a aplicar las transacciones que se agregaron al bloque.

5. En algún momento, el propietario (o no propietario) del token quiere retirarlo de Plasma Cash. Para hacer esto, llama a la función `startExit` y le pasa información sobre las 2 últimas transacciones del token, que confirman que él es el propietario del token. El contrato inteligente, utilizando el hash Merkle, verifica la presencia de transacciones en los bloques y envía el token para su retiro, lo que ocurrirá en dos semanas.

6. Si la operación de retiro del token se produjo con violaciones (el token se gastó después de que comenzó el procedimiento de retiro o el token ya era de otra persona antes del retiro), el propietario del token puede refutar el retiro dentro de dos semanas.

Prueba pública: una solución para la privacidad y la escalabilidad en Ethereum

La privacidad se logra de dos maneras

1. La cadena raíz no sabe nada acerca de las transacciones que se generan y reenvían dentro de la cadena secundaria. La información sobre quién depositó y retiró ETH de Plasma Cash sigue siendo pública.

2. La cadena secundaria permite transacciones anónimas utilizando zk-SNARK.

Pila de tecnología

  • NodeJS
  • Redis
  • Etherium
  • Soild

pruebas

Mientras desarrollábamos Plasma Cash, probamos la velocidad del sistema y obtuvimos los siguientes resultados:

  • se agregan al grupo hasta 35 transacciones por segundo;
  • Se pueden almacenar hasta 1 de transacciones en un bloque.

Las pruebas se realizaron en los siguientes 3 servidores:

1. Intel Core i7-6700 de cuatro núcleos Skylake incl. SSD NVMe: 512 GB, 64 GB de RAM DDR4
Se levantaron 3 nodos de validación de Plasma Cash.

2. AMD Ryzen 7 1700X Octa-Core “Summit Ridge” (Zen), SSD SATA – 500 GB, 64 GB de RAM DDR4
Se levantó el nodo ETH de Ropsten testnet.
Se levantaron 3 nodos de validación de Plasma Cash.

3. Intel Core i9-9900K Octa-Core incl. SSD NVMe: 1 TB, 64 GB de RAM DDR4
Se elevó 1 nodo de envío de Plasma Cash.
Se levantaron 3 nodos de validación de Plasma Cash.
Se lanzó una prueba para agregar transacciones a la red Plasma Cash.

Total: 10 nodos Plasma Cash en una red privada.

Prueba 1

Hay un límite de 1 millón de transacciones por bloque. Por lo tanto, 1 millón de transacciones caen en 2 bloques (ya que el sistema logra tomar parte de las transacciones y enviarlas mientras se envían).


Estado inicial: último bloque #7; En la base de datos se almacenan 1 millón de transacciones y tokens.

00:00 - inicio del script de generación de transacciones
01:37 - Se crearon 1 millón de transacciones y se inició el envío al nodo.
01:46: el nodo de envío tomó 240 transacciones del grupo y formó el bloque n.° 8. También vemos que se agregan 320 transacciones al grupo en 10 segundos.
01:58 - el bloque n.° 8 se firma y se envía para su validación
02:03: se valida el bloque n.° 8 y se llama a la función `submitBlock` del contrato inteligente con el hash de Merkle y el número de bloque
02:10: el script de demostración terminó de funcionar y envió 1 millón de transacciones en 32 segundos.
02:33 - los nodos comenzaron a recibir información de que el bloque #8 se agregó a la cadena raíz y comenzaron a realizar 240k transacciones
02:40 - Se eliminaron 240 transacciones del grupo, que ya están en el bloque n.° 8
02:56: el nodo de envío tomó las 760 transacciones restantes del grupo y comenzó a calcular el hash de Merkle y el bloque de firma n.° 9
03:20 - todos los nodos contienen 1 millón de 240k transacciones y tokens
03:35: el bloque n.° 9 se firma y se envía para su validación a otros nodos
03:41 - ocurrió un error de red
04:40 — se agotó el tiempo de espera de la validación del bloque n.° 9
04:54: el nodo de envío tomó las 760 transacciones restantes del grupo y comenzó a calcular el hash de Merkle y el bloque de firma n.° 9
05:32: el bloque n.° 9 se firma y se envía para su validación a otros nodos
05:53 - el bloque n.° 9 se valida y se envía a la cadena raíz
06:17 - los nodos comenzaron a recibir información de que el bloque #9 se agregó a la cadena raíz y comenzó a realizar 760k transacciones
06:47: el grupo se ha eliminado de las transacciones que están en el bloque n.° 9
09:06 - todos los nodos contienen 2 millones de transacciones y tokens

Prueba 2

Hay un límite de 350k por bloque. Como resultado, tenemos 3 bloques.


Estado inicial: último bloque #9; 2 millones de transacciones y tokens se almacenan en la base de datos.

00:00: el script de generación de transacciones ya se lanzó
00:44 - Se crearon 1 millón de transacciones y se inició el envío al nodo.
00:56: el nodo de envío tomó 320 transacciones del grupo y formó el bloque n.° 10. También vemos que se agregan 320 transacciones al grupo en 10 segundos.
01:12: el bloque n.° 10 se firma y se envía a otros nodos para su validación
01:18: el script de demostración terminó de funcionar y envió 1 millón de transacciones en 34 segundos.
01:20: el bloque n.° 10 se valida y se envía a la cadena raíz
01:51 - todos los nodos recibieron información de la cadena raíz de que se agregó el bloque n.° 10 y comenzaron a aplicar 320k transacciones
02:01: el grupo se ha liquidado para 320 transacciones que se agregaron al bloque n.° 10
02:15: el nodo de envío tomó 350 transacciones del grupo y formuló el bloque n.° 11
02:34: el bloque n.° 11 se firma y se envía a otros nodos para su validación
02:51 - el bloque n.° 11 se valida y se envía a la cadena raíz
02:55: el último nodo completó las transacciones del bloque n.° 10
10:59 — la transacción con el envío del bloque #9 tomó mucho tiempo en la cadena raíz, pero se completó y todos los nodos recibieron información al respecto y comenzaron a realizar 350k transacciones
11:05: el grupo se ha liquidado para 320 transacciones que se agregaron al bloque n.° 11
12:10: todos los nodos contienen 1 millón de 670 XNUMX transacciones y tokens
12:17: el nodo de envío tomó 330 transacciones del grupo y formuló el bloque n.° 12
12:32: el bloque n.° 12 se firma y se envía a otros nodos para su validación
12:39: el bloque n.° 12 se valida y se envía a la cadena raíz
13:44 - todos los nodos recibieron información de la cadena raíz de que se agregó el bloque #12 y comenzaron a aplicar 330k transacciones
14:50 - todos los nodos contienen 2 millones de transacciones y tokens

Prueba 3

En el primer y segundo servidor, un nodo de validación fue reemplazado por un nodo de envío.


Estado inicial: último bloque #84; 0 transacciones y tokens guardados en la base de datos

00:00 — Se han lanzado 3 scripts que generan y envían 1 millón de transacciones cada uno
01:38 - Se crearon 1 millón de transacciones y comenzó el envío al nodo de envío n.° 3
01:50: el nodo de envío n.° 3 tomó 330 85 transacciones del grupo y formó el bloque n.° 21 (f350). También vemos que se agregan 10 transacciones al grupo en XNUMX segundos.
01:53 - Se crearon 1 millón de transacciones y comenzó el envío al nodo de envío n.° 1
01:50: el nodo de envío n.° 3 tomó 330 85 transacciones del grupo y formó el bloque n.° 21 (f350). También vemos que se agregan 10 transacciones al grupo en XNUMX segundos.
02:01: el nodo de envío n.° 1 tomó 250 85 transacciones del grupo y formó el bloque n.° 65 (XNUMXe)
02:06: el bloque n.° 85 (f21) se firma y se envía a otros nodos para su validación
02:08 — el script de demostración del servidor n.° 3, que envió 1 millón de transacciones en 30 segundos, terminó de funcionar
02:14 - el bloque n.° 85 (f21) se valida y se envía a la cadena raíz
02:19: el bloque n.° 85 (65e) se firma y se envía a otros nodos para su validación
02:22 - Se crearon 1 millón de transacciones y comenzó el envío al nodo de envío n.° 2
02:27 - bloque #85 (65e) validado y enviado a la cadena raíz
02:29: el nodo de envío n.° 2 tomó 111855 transacciones del grupo y formó el bloque n.° 85 (256).
02:36: el bloque n.° 85 (256) se firma y se envía a otros nodos para su validación
02:36 — el script de demostración del servidor n.° 1, que envió 1 millón de transacciones en 42.5 segundos, terminó de funcionar
02:38 - el bloque n.° 85 (256) se valida y se envía a la cadena raíz
03:08: el script del servidor n.° 2 terminó de funcionar y envió 1 millón de transacciones en 47 segundos.
03:38 - todos los nodos recibieron información de la cadena raíz que bloquea #85 (f21), #86(65e), #87(256) se agregaron y comenzaron a aplicar 330k, 250k, 111855 transacciones
03:49 - el grupo se borró en 330k, 250k, 111855 transacciones que se agregaron a los bloques #85 (f21), #86(65e), #87(256)
03:59: el nodo de envío n.° 1 tomó 888145 transacciones del grupo y el bloque de formularios n.° 88 (214), el nodo de envío n.° 2 tomó 750 mil transacciones del grupo y el bloque de formularios n.° 88 (50a), el nodo de envío n.° 3 tomó 670 mil transacciones de la piscina y formas del bloque #88 (d3b)
04:44 — el bloque n.° 88 (d3b) se firma y se envía a otros nodos para su validación
04:58: el bloque n.° 88 (214) se firma y se envía a otros nodos para su validación
05:11: el bloque n.° 88 (50a) se firma y se envía a otros nodos para su validación
05:11 - el bloque n.° 85 (d3b) se valida y se envía a la cadena raíz
05:36 - el bloque n.° 85 (214) se valida y se envía a la cadena raíz
05:43 - todos los nodos recibieron información de la cadena raíz que bloquea #88 (d3b), #89(214) se han agregado y están comenzando a aplicar transacciones de 670k, 750k
06:50 — debido a una falla de comunicación, el bloque #85 (50a) no fue validado
06:55: el nodo de envío n.° 2 tomó 888145 transacciones del grupo y formó el bloque n.° 90 (50a)
08:14: el bloque n.° 90 (50a) se firma y se envía a otros nodos para su validación
09:04: el bloque n.° 90 (50a) se valida y se envía a la cadena raíz
11:23: todos los nodos recibieron información de la cadena raíz de que se agregó el bloque n.° 90 (50a) y comenzaron a aplicar 888145 transacciones. Al mismo tiempo, el servidor #3 ya ha aplicado transacciones de los bloques #88 (d3b), #89(214)
12:11 - todas las piscinas están vacías
13:41: todos los nodos del servidor n.° 3 contienen 3 millones de transacciones y tokens
14:35: todos los nodos del servidor n.° 1 contienen 3 millones de transacciones y tokens
19:24: todos los nodos del servidor n.° 2 contienen 3 millones de transacciones y tokens

Obstáculos

Durante el desarrollo de Plasma Cash, nos encontramos con los siguientes problemas, que gradualmente resolvimos y estamos resolviendo:

1. Conflicto en la interacción de varias funciones del sistema. Por ejemplo, la función de agregar transacciones al grupo bloqueó el trabajo de enviar y validar bloques, y viceversa, lo que provocó una caída en la velocidad.

2. No quedó claro de inmediato cómo enviar una gran cantidad de transacciones minimizando los costos de transferencia de datos.

3. No estaba claro cómo y dónde almacenar los datos para lograr altos resultados.

4. No estaba claro cómo organizar una red entre nodos, ya que el tamaño de un bloque con 1 millón de transacciones ocupa alrededor de 100 MB.

5. Trabajar en modo de subproceso único interrumpe la conexión entre nodos cuando se realizan cálculos largos (por ejemplo, construir un árbol Merkle y calcular su hash).

¿Cómo afrontamos todo esto?

La primera versión del nodo Plasma Cash era una especie de cosechadora que podía hacer todo al mismo tiempo: aceptar transacciones, enviar y validar bloques y proporcionar una API para acceder a datos. Dado que NodeJS tiene un solo subproceso de forma nativa, la pesada función de cálculo del árbol Merkle bloqueó la función de agregar transacciones. Vimos dos opciones para resolver este problema:

1. Inicie varios procesos de NodeJS, cada uno de los cuales realiza funciones específicas.

2. Utilice work_threads y mueva la ejecución de parte del código a subprocesos.

Como resultado, utilizamos ambas opciones al mismo tiempo: lógicamente dividimos un nodo en 3 partes que pueden funcionar por separado, pero al mismo tiempo de forma sincrónica.

1. Nodo de envío, que acepta transacciones en el grupo y crea bloques.

2. Un nodo de validación que comprueba la validez de los nodos.

3. Nodo API: proporciona una API para acceder a los datos.

En este caso, puede conectarse a cada nodo a través de un socket Unix usando cli.

Movimos las operaciones pesadas, como el cálculo del árbol de Merkle, a un hilo separado.

De esta forma, hemos conseguido el funcionamiento normal de todas las funciones de Plasma Cash de forma simultánea y sin fallos.

Una vez que el sistema estuvo funcional, comenzamos a probar la velocidad y, desafortunadamente, obtuvimos resultados insatisfactorios: 5 transacciones por segundo y hasta 000 50 transacciones por bloque. Tuve que descubrir qué se implementó incorrectamente.

Para empezar, comenzamos a probar el mecanismo de comunicación con Plasma Cash para descubrir la capacidad máxima del sistema. Escribimos anteriormente que el nodo Plasma Cash proporciona una interfaz de socket Unix. Inicialmente estaba basado en texto. Los objetos json se enviaron usando `JSON.parse()` y `JSON.stringify()`.

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

Medimos la velocidad de transferencia de dichos objetos y encontramos ~ 130k por segundo. Intentamos reemplazar las funciones estándar para trabajar con json, pero el rendimiento no mejoró. El motor V8 debe estar bien optimizado para estas operaciones.

Trabajamos con transacciones, tokens y bloques a través de clases. Al crear este tipo de clases, el rendimiento se redujo a la mitad, lo que indica que la programación orientada a objetos no es adecuada para nosotros. Tuve que reescribir todo con un enfoque puramente funcional.

Grabación en la base de datos.

Inicialmente, se eligió Redis para el almacenamiento de datos como una de las soluciones más productivas que satisface nuestros requisitos: almacenamiento de valores clave, trabajo con tablas hash, conjuntos. Lanzamos redis-benchmark y obtuvimos ~80 operaciones por segundo en 1 modo de canalización.

Para lograr un alto rendimiento, ajustamos Redis con mayor precisión:

  • Se ha establecido una conexión de socket Unix.
  • Desactivamos guardar el estado en el disco (para mayor confiabilidad, puede configurar una réplica y guardarla en el disco en un Redis separado).

En Redis, un grupo es una tabla hash porque necesitamos poder recuperar todas las transacciones en una consulta y eliminar las transacciones una por una. Intentamos usar una lista normal, pero es más lento al descargar la lista completa.

Al utilizar NodeJS estándar, las bibliotecas de Redis lograron un rendimiento de 18 transacciones por segundo. La velocidad disminuyó 9 veces.

Como el benchmark nos mostró que las posibilidades eran claramente 5 veces mayores, comenzamos a optimizar. Cambiamos la biblioteca a ioredis y obtuvimos un rendimiento de 25k por segundo. Agregamos transacciones una por una usando el comando `hset`. Entonces estábamos generando muchas consultas en Redis. Surgió la idea de combinar transacciones en lotes y enviarlas con un comando "hmset". El resultado es 32k por segundo.

Por varias razones, que describiremos a continuación, trabajamos con datos usando `Buffer` y, resulta que si los convierte a texto (`buffer.toString('hex')`) antes de escribir, puede obtener información adicional. actuación. Así, la velocidad se incrementó a 35k por segundo. Por el momento hemos decidido suspender la optimización adicional.

Tuvimos que cambiar a un protocolo binario porque:

1. El sistema a menudo calcula hashes, firmas, etc., y para ello necesita datos en el `Buffer.

2. Cuando se envían entre servicios, los datos binarios pesan menos que el texto. Por ejemplo, al enviar un bloque con 1 millón de transacciones, los datos del texto pueden ocupar más de 300 megabytes.

3. La transformación constante de los datos afecta el rendimiento.

Por lo tanto, tomamos como base nuestro propio protocolo binario para almacenar y transmitir datos, desarrollado sobre la base de la maravillosa biblioteca "binary-data".

Como resultado, obtuvimos las siguientes estructuras de datos:

-Transacción

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

— Ficha

  ```json
  {
    id: BD.types.string(null),
    owner: BD.types.buffer(20),
    block: BD.types.uint24le,
    amount: BD.types.string(null),
  }
  ```

-Bloquear

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

Con los comandos habituales `BD.encode(block, Protocol).slice();` y `BD.decode(buffer, Protocol)` convertimos los datos en `Buffer` para guardarlos en Redis o reenviarlos a otro nodo y recuperar el datos de vuelta.

También disponemos de 2 protocolos binarios para la transferencia de datos entre servicios:

— Protocolo de interacción con Plasma Node a travé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)
  }
  ```

donde:

  • `Type` — la acción a realizar, por ejemplo, 1 — enviarTransacción, 2 — obtenerTransacción;
  • `carga útil` — datos que deben pasarse a la función apropiada;
  • `Id del mensaje` — ID del mensaje para que se pueda identificar la respuesta.

— Protocolo de interacción entre nodos.

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

donde:

  • `código` — código de mensaje, por ejemplo 6 — PREPARE_NEW_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
  • `versiónProtocolo` — versión del protocolo, ya que en la red se pueden generar nodos con diferentes versiones y pueden funcionar de manera diferente;
  • `seq` — identificador de mensaje;
  • `contarChunk` и `número de fragmento` necesario para dividir mensajes grandes;
  • `longitud` и `carga útil` longitud y los datos en sí.

Dado que escribimos previamente los datos, el sistema final es mucho más rápido que la biblioteca `rlp` de Ethereum. Desafortunadamente, todavía no hemos podido rechazarlo, ya que es necesario finalizar el contrato inteligente, lo que planeamos hacer en el futuro.

Si logramos alcanzar la velocidad 35 000 transacciones por segundo, también necesitamos procesarlas en el tiempo óptimo. Dado que el tiempo aproximado de formación del bloque toma 30 segundos, debemos incluir en el bloque 1 000 000 transacciones, lo que significa enviar más 100 MB de datos.

Inicialmente, usamos la biblioteca `ethereumjs-devp2p` para comunicarnos entre nodos, pero no podía manejar tantos datos. Como resultado, utilizamos la biblioteca `ws` y configuramos el envío de datos binarios a través de websocket. Por supuesto, también encontramos problemas al enviar paquetes de datos grandes, pero los dividimos en fragmentos y ahora esos problemas desaparecieron.

También formando un árbol Merkle y calculando el hash. 1 000 000 las transacciones requieren aproximadamente 10 segundos de cálculo continuo. Durante este tiempo, la conexión con todos los nodos logra romperse. Se decidió trasladar este cálculo a un hilo aparte.

Conclusiones:

De hecho, nuestros hallazgos no son nuevos, pero por alguna razón muchos expertos los olvidan al desarrollarlos.

  • El uso de programación funcional en lugar de programación orientada a objetos mejora la productividad.
  • El monolito es peor que una arquitectura de servicios para un sistema NodeJS productivo.
  • El uso de `worker_threads` para cálculos pesados ​​mejora la capacidad de respuesta del sistema, especialmente cuando se trata de operaciones de E/S.
  • El socket Unix es más estable y más rápido que las solicitudes http.
  • Si necesita transferir rápidamente una gran cantidad de datos a través de la red, es mejor usar websockets y enviar datos binarios, divididos en fragmentos, que se pueden reenviar si no llegan y luego combinarlos en un solo mensaje.

Te invitamos a visitar GitHub proyecto: https://github.com/opporty-com/Plasma-Cash/tree/new-version

El artículo fue coescrito por Alejandro Nashivan, desarrollador Senior Soluciones inteligentes Inc..

Fuente: habr.com

Añadir un comentario