Blockchain — una tecnología innovadora que promete mejorar muchos aspectos de la vida humana. Transfiere procesos y productos reales al espacio digital, asegura la velocidad y confiabilidad de las transacciones financieras, reduce sus costos y también permite crear aplicaciones DAPP modernas utilizando contratos inteligentes en redes descentralizadas.
Considerando las numerosas ventajas y diversas áreas de aplicación del blockchain, puede parecer extraño que esta prometedora tecnología aún no haya penetrado en todas las industrias. El problema es que los blockchains descentralizados actuales carecen de escalabilidad. Ethereum procesa alrededor de 20 transacciones por segundo, lo cual es insuficiente para satisfacer las necesidades de los negocios dinámicos actuales. Al mismo tiempo, las empresas que utilizan la tecnología blockchain no se atreven a abandonar Ethereum debido a su alto grado de protección contra hacks y fallos de red.
Para asegurar descentralización, seguridad y escalabilidad en el blockchain, resolviendo así la Trilema de Escalabilidad, el equipo de desarrolladores creó Plasma Cash — una cadena lateral compuesta por un contrato inteligente y una red privada basada en Node.js que transmite periódicamente su estado a la cadena principal (Ethereum).

Los procesos clave en Plasma Cash
1. El usuario invoca la función del contrato inteligente `deposit`, transmitiéndole la cantidad en ETH que desea depositar en el token Plasma Cash. La función del contrato inteligente crea el token y genera un evento al respecto.
2. Los nodos de Plasma Cash, suscritos a los eventos del contrato inteligente, reciben el evento de creación del depósito y añaden a la pool la transacción de creación del token.
3. Periódicamente, nodos especiales de Plasma Cash toman todas las transacciones de la pool (hasta 1 millón) y forman un bloque a partir de ellas, calculan el árbol de Merkle y, en consecuencia, el hash. Este bloque se envía a otros nodos para verificación. Los nodos comprueban 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). Tras la verificación del bloque, el nodo invoca la función `submitBlock` del contrato inteligente, que guarda en la cadena principal el número y el hash de Merkle del bloque. El contrato inteligente genera un evento de adición exitosa del bloque. Las transacciones se eliminan de la pool.
4. Los nodos que reciben el evento de envío del bloque comienzan a aplicar las transacciones que se han añadido al bloque.
5. En algún momento, el propietario (o no propietario) del token desea retirarlo de Plasma Cash. Para ello, invoca la función `startExit`, proporcionando información sobre las últimas 2 transacciones del token, que confirman que él es el propietario del token. El contrato inteligente, utilizando el hash Merkle, verifica la presencia de las transacciones en los bloques y envía el token para que se retire, lo cual ocurrirá en dos semanas.
6. Si la operación de retiro del token se realizó de manera indebida (el token fue gastado después de iniciar el procedimiento de retiro o el token ya era ajeno antes del retiro), el propietario del token puede impugnar el retiro dentro de un plazo de dos semanas.

La privacidad se logra de dos maneras
1. La cadena raíz no conoce las transacciones que se generan y envían dentro de la cadena secundaria. La información pública se mantiene sobre quién ingresó y retiró ETH en/desde Plasma Cash.
2. La cadena secundaria permite organizar transacciones anónimas utilizando zk-SNARKs.
Stack tecnológico
- NodeJS
- Redis
- Etherium
- Soild
Pruebas
Al desarrollar Plasma Cash, probamos la velocidad del sistema y obtuvimos los siguientes resultados:
- hasta 35,000 transacciones por segundo se añaden al pool;
- hasta 1,000,000 transacciones pueden almacenarse en un bloque.
Las pruebas se llevaron a cabo en los siguientes 3 servidores:
1. Intel Core i7-6700 Quad-Core Skylake incl. NVMe SSD — 512 GB, 64 GB DDR4 RAM
Se levantaron 3 nodos validados de Plasma Cash.
2. AMD Ryzen 7 1700X Octa-Core «Summit Ridge» (Zen), SATA SSD — 500 GB, 64 GB DDR4 RAM
Se levantó un nodo de ETH en la testnet de Ropsten.
Se levantaron 3 nodos validados de Plasma Cash.
3. Intel Core i9-9900K Octa-Core incl. NVMe SSD — 1 TB, 64 GB DDR4 RAM
Se levantó 1 nodo de envío de Plasma Cash.
Se levantaron 3 nodos validados de Plasma Cash.
Se ejecutó una prueba sobre el envío de transacciones en la red de Plasma Cash.
Total: 10 nodos de 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 se dividen en 2 bloques (ya que el sistema puede tomar parte de las transacciones y enviarlas mientras se están enviando).

Estado inicial: bloque final #7; se han guardado 1 millón de transacciones y tokens en la base.
00:00 — inicio del script de generación de transacciones
01:37 — se han creado 1 millón de transacciones y se ha iniciado el envío al nodo
01:46 — el nodo de envío tomó del pool 240k transacciones y está formando el bloque #8. También vemos que se añaden 320k transacciones al pool en 10 segundos
01:58 — bloque #8 firmado y enviado a validación
02:03 — el bloque #8 ha sido validado y se ha llamado a la función `submitBlock` del contrato inteligente con el hash de Merkle y el número de bloque
02:10 — se ha terminado de ejecutar el script de demostración, que envió 1 millón de transacciones en 32 segundos
02:33 — los nodos han comenzado a recibir información sobre que el bloque #8 se ha agregado a la cadena raíz y han comenzado a procesar 240 mil transacciones
02:40 — se han eliminado 240 mil transacciones del pool, que ya están en el bloque #8
02:56 — el nodo de envío tomó del pool las restantes 760 mil transacciones y comenzó a calcular el hash de Merkle y a firmar el bloque #9
03:20 — todos los nodos contienen 1 millón 240 mil transacciones y tokens
03:35 — el bloque #9 ha sido firmado y se envía a validación a otros nodos
03:41 — ocurrió un error de red
04:40 — se ha agotado el tiempo de espera para la validación del bloque #9
04:54 — el nodo de envío tomó del pool las restantes 760 mil transacciones y comenzó a calcular el hash de Merkle y a firmar el bloque #9
05:32 — el bloque #9 ha sido firmado y se envía a validación a otros nodos
05:53 — el bloque #9 ha sido validado y enviado a la cadena raíz
06:17 — los nodos han comenzado a recibir información sobre que el bloque #9 se ha agregado a la cadena raíz y han comenzado a procesar 760 mil transacciones
06:47 — el pool se ha limpiado de las transacciones en el bloque #9
09:06 — todos los nodos contienen 2 millones de transacciones y tokens
Test 2
Hay un límite de 350k por bloque. Como resultado, tenemos 3 bloques.

Estado inicial: último bloque #9; en la base se han guardado 2 millones de transacciones y tokens
00:00 — el script de generación de transacciones ya está en ejecución
00:44 — se han creado 1 millón de transacciones y se ha comenzado a enviarlas al nodo
00:56 — el nodo de envío tomó del pool 320 mil transacciones y está formando el bloque #10. También vemos que se agregan 320 mil transacciones al pool en 10 segundos
01:12 — el bloque #10 ha sido firmado y se envía a otros nodos para validación
01:18 — se ha terminado de ejecutar el script de demostración, que envió 1 millón de transacciones en 34 segundos
01:20 — el bloque #10 ha sido validado y enviado a la cadena raíz
01:51 — todos los nodos han recibido de la cadena raíz información sobre que el bloque #10 ha sido agregado, y comienzan a aplicar 320 mil transacciones
02:01 — el pool se ha limpiado de 320 mil transacciones, que fueron agregadas al bloque #10
02:15 — el nodo de envío tomó del pool 350 mil transacciones y está formando el bloque #11
02:34 — el bloque #11 ha sido firmado y se envía a otros nodos para validación
02:51 — el bloque #11 ha sido validado y enviado a la cadena raíz
02:55 — el último nodo ha completado las transacciones del bloque #10
10:59 — se ha tardado mucho en procesar la transacción en la cadena raíz con la presentación del bloque #9, pero se completó y todos los nodos recibieron la información y comenzaron a procesar 350k transacciones.
11:05 — el pool se ha limpiado de 320k transacciones, que se añadieron al bloque #11.
12:10 — todos los nodos contienen 1 millón 670k transacciones y tokens.
12:17 — el nodo de presentación tomó del pool 330k transacciones y está formando el bloque #12.
12:32 — el bloque #12 ha sido firmado y se envía a otros nodos para validación.
12:39 — el bloque #12 fue validado y enviado a la cadena raíz.
13:44 — todos los nodos recibieron de la cadena raíz la información de que el bloque #12 fue añadido y comienzan 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 validante fue reemplazado por un nodo de presentación.

Estado inicial: último bloque #84; en la base se han guardado 0 transacciones y tokens.
00:00 — Se han iniciado 3 scripts que generan y envían 1 millón de transacciones cada uno.
01:38 — se han creado 1 millón de transacciones y comenzó el envío al nodo de presentación #3.
01:50 — el nodo de presentación #3 tomó del pool 330k transacciones y está formando el bloque #85 (f21). También vemos que se añaden 350k transacciones al pool en 10 segundos.
01:53 — se han creado 1 millón de transacciones y comenzó el envío al nodo de presentación #1.
01:50 — el nodo de presentación #3 tomó del pool 330k transacciones y está formando el bloque #85 (f21). También vemos que se añaden 350k transacciones al pool en 10 segundos.
02:01 — el nodo de presentación #1 tomó del pool 250k transacciones y está formando el bloque #85 (65e).
02:06 — el bloque #85 (f21) ha sido firmado y se envía a otros nodos para validación.
02:08 — ha finalizado la ejecución del script demo del servidor #3, que envió 1 millón de transacciones en 30 segundos.
02:14 — el bloque #85 (f21) fue validado y enviado a la cadena raíz.
02:19 — el bloque #85 (65e) ha sido firmado y se envía a otros nodos para validación.
02:22 — se han creado 1 millón de transacciones y comenzó el envío al nodo de presentación #2.
02:27 — el bloque #85 (65e) fue validado y enviado a la cadena raíz.
02:29 — el nodo de presentación #2 tomó del pool 111855 transacciones y está formando el bloque #85 (256).
02:36 — el bloque #85 (256) ha sido firmado y se envía a otros nodos para validación.
02:36 — ha finalizado la ejecución del script demo del servidor #1, que envió 1 millón de transacciones en 42.5 segundos.
02:38 — el bloque #85 (256) fue validado y enviado a la cadena raíz.
03:08 — ha finalizado la ejecución del script demo del servidor #2, que envió 1 millón de transacciones en 47 segundos.
03:38 — todos los nodos recibieron de la cadena raíz la información de que los bloques #85 (f21), #86(65e), #87(256) fueron añadidos y comienzan a aplicar 330k, 250k, 111855 transacciones.
03:49 — el pool se limpió de 330k, 250k, 111855 transacciones, que fueron añadidas a los bloques #85 (f21), #86(65e), #87(256)
03:59 — el submit del nodo #1 tomó 888145 transacciones del pool y está formando el bloque #88 (214), el submit del nodo #2 tomó del pool 750k transacciones y está formando el bloque #88 (50a), el submit del nodo #3 tomó 670k transacciones del pool y está formando el bloque #88 (d3b)
04:44 — el bloque #88 (d3b) ha sido firmado y se envía a otros nodos para validación
04:58 — el bloque #88 (214) ha sido firmado y se envía a otros nodos para validación
05:11 — el bloque #88 (50a) ha sido firmado y se envía a otros nodos para validación
05:11 — el bloque #85 (d3b) ha sido validado y enviado a la cadena raíz
05:36 — el bloque #85 (214) ha sido validado y enviado a la cadena raíz
05:43 — todos los nodos recibieron de la cadena raíz información sobre que los bloques #88 (d3b), #89(214) han sido añadidos y comienzan a aplicar 670k, 750k transacciones
06:50 — debido a la interrupción de la conexión, el bloque #85 (50a) no fue validado
06:55 — el submit del nodo #2 tomó 888145 transacciones del pool y está formando el bloque #90 (50a)
08:14 — el bloque #90 (50a) ha sido firmado y se envía a otros nodos para validación
09:04 — el bloque #90 (50a) ha sido validado y enviado a la cadena raíz
11:23 — todos los nodos recibieron de la cadena raíz información de que el bloque #90 (50a) ha sido añadido, y comienzan a aplicar 888145 transacciones. A su vez, el servidor #3 ya había aplicado transacciones de los bloques #88 (d3b), #89(214)
12:11 — todos los pools están vacíos
13:41 — todos los nodos del servidor #3 contienen 3 millones de transacciones y tokens
14:35 — todos los nodos del servidor #1 contienen 3 millones de transacciones y tokens
19:24 — todos los nodos del servidor #2 contienen 3 millones de transacciones y tokens
Obstáculos
Durante el desarrollo de Plasma Cash, nos enfrentamos a los siguientes problemas, que hemos resuelto y continuamos resolviendo:
1. Conflicto en la interacción de diversas funciones del sistema. Por ejemplo, la función de añadir transacciones al pool bloqueaba el trabajo de submit y validación de bloques, y viceversa, lo que llevaba a una disminución de la velocidad.
2. No estaba claro cómo enviar una gran cantidad de transacciones y al mismo tiempo minimizar los costos de transmisión de datos.
3. No estaba claro cómo y dónde almacenar los datos para alcanzar altos resultados.
4. No estaba claro cómo organizar la red entre los nodos, ya que el tamaño de un bloque con 1 millón de transacciones ocupa alrededor de 100 MB.
5. El funcionamiento en modo de un solo hilo rompe la conexión entre los nodos cuando se llevan a cabo cálculos prolongados (por ejemplo, la construcción del árbol Merkle y el cálculo de su hash).
¿Cómo manejamos todo esto?
La primera versión del nodo de Plasma Cash era una especie de combinación que podía hacer todo simultáneamente: aceptar transacciones, enviar y validar bloques, y proporcionaba una API para acceder a los datos. Dado que NodeJS es originalmente de un solo hilo, la función pesada de cálculo del árbol Merkle bloqueaba la función de adición de transacciones. Vimos dos opciones para resolver este problema:
1. Ejecutar varios procesos de NodeJS, cada uno de los cuales realiza funciones específicas.
2. Utilizar worker_threads y trasladar la ejecución de parte del código a hilos.
Al final, utilizamos ambas opciones al mismo tiempo: dividimos lógicamente un nodo en 3 partes que pueden funcionar por separado, pero a la vez de forma sincrónica.
1. Nodo de envío, que acepta transacciones en el pool y se encarga de la creación de bloques.
2. Nodo de validación, que verifica la validez de los nodos.
3. Nodo API, que proporciona una API para acceder a los datos.
Además, se puede conectar a cada nodo a través de un socket unix mediante cli.
Las operaciones pesadas, como el cálculo del árbol Merkle, las trasladamos a un hilo separado.
De esta manera, logramos que todas las funciones de Plasma Cash funcionen simultáneamente y sin fallas.
Una vez que el sistema comenzó a funcionar funcionalmente, comenzamos a probar la velocidad y, desafortunadamente, obtuvimos resultados insatisfactorios: 5,000 transacciones por segundo y hasta 50,000 transacciones por bloque. Tuvimos que averiguar qué se había implementado incorrectamente.
Primero, comenzamos a probar el mecanismo de comunicación con Plasma Cash para descubrir la capacidad máxima del sistema. Anteriormente, mencionamos que el nodo de Plasma Cash proporciona una interfaz de socket unix. Inicialmente era de texto. Los objetos json se enviaban utilizando `JSON.parse()` y `JSON.stringify()`.
{
"action": "sendTransaction",
"payload":{
"prevHash": "0x8a88cc4217745fd0b4eb161f6923235da10593be66b841d47da86b9cd95d93e0",
"prevBlock": 41,
"tokenId": "57570139642005649136210751546585740989890521125187435281313126554130572876445",
"newOwner": "0x200eabe5b26e547446ae5821622892291632d4f4",
"type": "pay",
"data": "",
"signature": "0xd1107d0c6df15e01e168e631a386363c72206cb75b233f8f3cf883134854967e1cd9b3306cc5c0ce58f0a7397ae9b2487501b56695fe3a3c90ec0f61c7ea4a721c"
}
}
Medimos la velocidad de transferencia de tales objetos y obtuvimos ~ 130k por segundo. Intentamos reemplazar las funciones estándar para trabajar con JSON, pero no mejoró el rendimiento. Debe ser que el motor V8 está bien optimizado para estas operaciones.
El trabajo con transacciones, tokens y bloques se realizó a través de clases. Al crear tales clases, el rendimiento se redujo a la mitad, lo que indica que la POO no nos conviene. Tuvimos que reescribir todo en un enfoque puramente funcional.
Escritura en la base de datos
Inicialmente, elegimos Redis para el almacenamiento de datos como una de las soluciones más eficientes que satisface nuestros requisitos: almacenamiento key-value, trabajo con tablas hash, conjuntos. Ejecutamos redis-benchmark y obtuvimos ~80k operaciones por segundo en modo de 1 pipelining.
Para un alto rendimiento, configuramos Redis más finamente:
- Establecimos conexión por socket unix.
- Desactivamos el guardado de estado en disco (para mayor fiabilidad, se puede configurar una réplica y realizar el guardado en disco en un Redis separado).
En Redis, el pool es una tabla hash, ya que necesitamos la capacidad de obtener todas las transacciones en una sola solicitud y eliminar transacciones individualmente. Intentamos usar una lista común, pero funcionaba más lentamente al descargar toda la lista.
Al utilizar la biblioteca estándar de NodeJS para Redis, obtuvimos un rendimiento de 18k transacciones por segundo. La velocidad cayó 9 veces.
Dado que el benchmark nos mostraba capacidades claramente 5 veces mayores, comenzamos a optimizar. Cambiamos la biblioteca a ioredis y logramos un rendimiento de 25k por segundo. Agregamos las transacciones una a una, usando el comando `hset`. De esta manera, generamos muchas solicitudes a Redis. Surgió la idea de agrupar transacciones en lotes y enviarlas con un solo comando `hmset`. El resultado — 32k por segundo.
Por varias razones que describiremos a continuación, trabajamos con los datos utilizando `Buffer` y, como resultó, si lo convertimos en texto (`buffer.toString('hex')`) antes de escribir, podemos obtener un rendimiento adicional. De esta manera, pudimos aumentar la velocidad a 35k por segundo. En este momento, decidimos detener la optimización adicional.
Tuvimos que pasar al protocolo binario porque:
1. El sistema a menudo calcula hashes, firmas, etc., y para ello necesita los datos en `Buffer.
2. Al transferir entre servicios, los datos binarios pesan menos que el texto. Por ejemplo, al enviar un bloque con 1 millón de transacciones, los datos en texto pueden ocupar más de 300 megabytes.
3. La conversión constante de datos afecta al rendimiento.
Por lo tanto, tomamos como base nuestro propio protocolo binario de almacenamiento y transmisión de datos, desarrollado sobre 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,
}
```
— Token
```json
{
id: BD.types.string(null),
owner: BD.types.buffer(20),
block: BD.types.uint24le,
amount: BD.types.string(null),
}
```
— Bloque
```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 `BD.encode(block, Protocol).slice();` y ` BD.decode(buffer, Protocol)` transformamos los datos en un `Buffer` para guardarlos en Redis o enviarlos a otro nodo y extraer los datos de nuevo.
También tenemos 2 protocolos binarios para la transmisión de datos entre servicios:
— Protocolo para interactuar con Plasma Node a través de un 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` — acción que debe realizarse, por ejemplo, 1 — sendTransaction, 2 — getTransaction;
- `payload` — datos que deben ser enviados a la función correspondiente;
- `messageId` — 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:
- `code` — código del mensaje, por ejemplo 6 — PREPARE_NEW_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
- `versionProtocol` — versión del protocolo, ya que en la red pueden haber nodos con diferentes versiones y pueden funcionar de manera distinta;
- `seq` — identificador del mensaje;
- `countChunk` y `chunkNumber` necesarios para fragmentar grandes mensajes;
- `length` y `payload` longitud y los propios datos.
Dado que tipificamos los datos de antemano, el sistema final funciona mucho más rápido que la biblioteca `rlp` de Ethereum. Desafortunadamente, aún no hemos podido prescindir de ella, ya que es necesario mejorar el contrato inteligente, lo que planeamos hacer en el futuro.
Si hemos logrado alcanzar una velocidad 35 000 de transacciones por segundo, también necesitamos procesarlas en el tiempo óptimo. Dado que el tiempo aproximado para la formación de un bloque es de 30 segundos, necesitamos incluir en el bloque 1 000 000 transacciones, lo que significa enviar más de 100 MB de datos.
Inicialmente, utilizamos la biblioteca `ethereumjs-devp2p` para la comunicación entre nodos, pero no manejaba tal cantidad de 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 grandes paquetes de datos, pero los dividimos en fragmentos y ahora no hay esos problemas.
Además, la formación del árbol de Merkle y el cálculo del hash 1 000 000 de las transacciones requiere alrededor de 10 segundos de cálculo continuo. Durante este tiempo, la conexión con todos los nodos puede interrumpirse. Se decidió trasladar este cálculo a un hilo separado.
Conclusiones:
De hecho, nuestras conclusiones no son nuevas, pero por alguna razón muchos especialistas las olvidan al desarrollar.
- Usar Programación Funcional en lugar de Programación Orientada a Objetos aumenta el rendimiento.
- Un monolito es peor que una arquitectura de servicios para un sistema de alto rendimiento en NodeJS.
- Usar `worker_threads` para cálculos pesados mejora la capacidad de respuesta del sistema, especialmente al trabajar con operaciones de i/o.
- El socket unix es más estable y rápido que las solicitudes http.
- Si se necesitan transferir grandes datos rápidamente a través de la red, es mejor usar websockets y enviar datos binarios divididos en fragmentos, que se pueden re-enviar si no llegan, y luego combinarlos en un solo mensaje.
Te invitamos a visitar GitHub el proyecto:
El artículo fue escrito en colaboración con Alexander Nashivan, desarrollador senior de .
Fuente: habr.com
