Capítulo 08 de 10 · 12 min de lectura
La red y la mempool
No hay servidor. Cada nodo habla con unos pocos vecinos, y la información corre de boca en boca como un rumor. Lo sorprendente es que, sin que nadie coordine nada, en segundos todos saben lo mismo.
Un rumor bien organizado
Hasta aquí todo pasaba dentro de un nodo. Pero el capítulo 1 exigía que el libro de cuentas lo tuvieran todos, y para eso los nodos tienen que hablar. Lo hacen de la forma más simple posible:
- Cada nodo mantiene conexiones TCP con unos pocos vecinos (ocho salientes en xavicoin y en Bitcoin, más las entrantes que acepte). Nadie está conectado a todos; nadie tiene una lista completa de la red.
- Cuando un nodo se entera de algo nuevo, se lo cuenta a sus vecinos. Estos lo validan, y si es correcto, se lo cuentan a los suyos.
Eso es todo. Se llama gossip, cotilleo, y tiene una propiedad matemática que lo hace imbatible: si cada nodo tiene k vecinos, un mensaje llega a toda una red de n nodos en unos logk(n) saltos. Con 8 vecinos y 20.000 nodos, unos cinco saltos. Segundos.
Mira cómo un pago recorre esta red, y luego cómo lo hace un bloque:
Activa JavaScript para usar la simulación.
Observa dos detalles del protocolo, porque los dos existen por una razón:
Primero se anuncia, luego se envía. Un nodo nunca manda un bloque entero sin preguntar. Envía un inv (inventario: “tengo esto”, solo el hash), y el vecino responde con getdata (“pues envíamelo”) solo si no lo tiene ya. Como cada nodo recibe el mismo anuncio de varios vecinos, sin este paso cada bloque viajaría por la red tantas veces como enlaces hay. Con él, viaja una vez por nodo.
// internal/p2p/handlers.go — handleInv (fragmento)
for _, item := range items {
p.markKnown(item.Hash)
switch item.Type {
case invBlock:
if !s.chain.HaveBlock(item.Hash) && s.reserveBlock(p, item.Hash) {
wanted = append(wanted, item)
}
case invTx:
if !s.pool.Has(item.Hash) {
wanted = append(wanted, item)
}
}
}
if len(wanted) > 0 {
p.queue(cmdGetData, encodeInv(wanted))
}
Ningún nodo reenvía nada sin validarlo antes. Cada nodo comprueba la transacción (capítulo 4) o el bloque (capítulos 5 y 6) por su cuenta, y solo si pasa lo anuncia a sus vecinos. Un dato inválido muere en el primer nodo que lo recibe: la red entera es un filtro.
La mempool: la sala de espera
Un pago que ha llegado a un nodo pero todavía no está en ningún bloque vive en su mempool (memory pool). Es una lista de transacciones válidas a la espera de que un minero las incluya. Tres cosas que conviene tener claras:
- Cada nodo tiene la suya, y no forma parte del consenso. Dos nodos pueden tener mempools distintas y no pasa nada: la que cuenta es la cadena.
- Es donde se decide el doble gasto provisionalmente. Si llegan dos pagos que gastan la misma moneda, el nodo se queda con el primero que vio (capítulo 1). Cuál de los dos gana de verdad lo dirá el bloque.
- Tiene precio de entrada. Un nodo solo acepta y reenvía transacciones que paguen una comisión mínima por byte. Sin ese mínimo, inundar las mempools de toda la red con basura saldría gratis.
Cuando llega un bloque, las transacciones que contiene salen de la mempool: ya están confirmadas. Y si el bloque confirma un gasto que contradice a uno de la mempool, el perdedor se expulsa, junto con cualquier transacción que dependiera de él.
Cómo se une un nodo nuevo
Un nodo recién instalado no conoce a nadie. Tiene una lista corta de semillas (direcciones fijadas en la configuración) y el resto lo descubre preguntando:
- Se conecta a una semilla e intercambian un saludo:
version(“soy este programa, voy por la altura tal”) yverack(“de acuerdo”). Hasta que el saludo no está completo, cualquier otro mensaje es motivo de desconexión. - Pide direcciones con
getaddr, y el vecino responde conaddr: una muestra de los nodos que conoce. Así crece su agenda, y con ella elige a quién llamar. - Pide la cadena. Y aquí hay un truco que merece su propio apartado.
Sincronizar por cabeceras
Un nodo nuevo tiene que descargar toda la historia, que en Bitcoin son cientos de gigas. Si pidiera bloques a ciegas a un vecino, un vecino malicioso podría hacerle descargar gigas de basura antes de que se diera cuenta.
La solución es sincronizar primero las cabeceras: 80 bytes por bloque. El nodo envía getheaders con un resumen de lo que ya tiene, y recibe hasta 500 cabeceras de golpe. Con ellas comprueba dos cosas sin descargar nada más: que encadenan (cada una apunta a la anterior) y que cada una lleva su prueba de trabajo. Fabricar 500 cabeceras falsas costaría lo mismo que minar 500 bloques. Solo cuando las cabeceras cuadran se piden los bloques completos, que ya se sabe que van a encajar.
// internal/p2p/handlers.go — handleHeaders (fragmento)
for i := range headers {
h := &headers[i]
if i > 0 && h.PrevBlock != headers[i-1].Hash() {
return s.misbehave(p, banThreshold, "cabeceras que no encadenan")
}
hash := h.Hash()
if !core.CheckProofOfWork(hash, h.Bits, s.cfg.Params.PowLimit()) {
return s.misbehave(p, banThreshold, "cabecera sin prueba de trabajo")
}
// … y solo entonces se pide el bloque
Nadie se fía de nadie
Un nodo de xavicoin habla con desconocidos, y algunos mentirán. Las defensas son pocas y sencillas:
- Cada mensaje lleva un prefijo de red y un checksum. Un nodo de otra red (o una conexión corrupta) se detecta en el primer mensaje.
- Todo tiene tamaño máximo: mensajes, listas, cabeceras por respuesta. Nadie puede hacerte reservar gigas de memoria anunciando “un millón de elementos” en un mensaje de diez bytes.
- Puntuación y veto. Cada dato inválido suma puntos al vecino que lo envió. Al llegar a 100, se le desconecta y se veta su IP durante un tiempo. Un bloque sin prueba de trabajo son 100 puntos de golpe, como acabas de ver en la simulación: validar cuesta CPU, y esto limita cuánta puede hacerte gastar un atacante.
// internal/p2p/server.go — misbehave (fragmento)
p.score += points
if score < banThreshold {
return nil
}
s.banned[host] = time.Now().Add(s.cfg.BanDuration)
return errors.New("vetado: " + reason)
- Las conexiones que importan son las que eliges tú. Un atacante puede abrirte mil conexiones entrantes, pero no decidir a quién llamas. Por eso el nodo mantiene siempre conexiones salientes a direcciones elegidas al azar de su agenda: es su ventana al mundo real, y no la controla nadie más.
En xavicoin
El protocolo entero son doce mensajes. Los nombres son los mismos que en Bitcoin, y el formato de los mensajes también (un prefijo de red, un comando de 12 bytes, la longitud y un checksum):
// internal/p2p/message.go
cmdVersion = "version" // saludo: quién soy y por qué altura voy
cmdVerack = "verack" // saludo aceptado
cmdPing = "ping" // ¿sigues ahí?
cmdPong = "pong" //
cmdGetAddr = "getaddr" // dime qué otros nodos conoces
cmdAddr = "addr" // direcciones de otros nodos
cmdInv = "inv" // "tengo esto" (solo hashes)
cmdGetData = "getdata" // "pues envíamelo"
cmdTx = "tx" // una transacción
cmdBlock = "block" // un bloque
cmdGetHeaders = "getheaders" // dame las cabeceras que siguen a este punto
cmdHeaders = "headers" // cabeceras
Y la propagación es una función de cinco líneas: todo lo que entra en la cadena o en la mempool, venga de donde venga (la red, el minero propio o la wallet), se anuncia a todos los vecinos que aún no lo conocen.
// internal/p2p/server.go
func (s *Server) announce(item invItem) {
payload := encodeInv([]invItem{item})
for _, p := range s.readyPeers() {
if p.markKnown(item.Hash) {
p.tryQueue(cmdInv, payload)
}
}
}
Lo que viene
La simulación de esta página termina siempre bien: todos los nodos en la misma altura. En una red real no siempre es así. Dos mineros pueden encontrar un bloque casi a la vez, y durante un rato la red tiene dos versiones de la historia. Cómo se resuelve eso, sin que nadie decida, es el capítulo que cierra el círculo abierto en el primero.