Red (Networking)

Cómo conectarte y comunicarte con un nodo de la red Bitcoin

El código introductorio de esta página funciona para nodos hasta la v26.2.

Bitcoin Core v27.0 (publicado en abril de 2024) y versiones posteriores usan por defecto el protocolo versión 2 (BIP 324). Los mensajes subyacentes son los mismos; la diferencia es que ahora van cifrados, y eso no se cubre en esta guía.

Si ejecutas un nodo v27.0 o superior, todavía puedes comunicarte con él usando el código de ejemplo de esta página si defines -v2transport=0 (desactivando el protocolo v2 y usando el antiguo v1).

Aquí tienes una guía rápida sobre cómo conectarte y comunicarte con un nodo de la red Bitcoin.

Animación de terminal mostrando una conexión a un nodo Bitcoin y los mensajes enviados.

0. Introducción

Bitcoin es un programa de computadora. Puedes descargarlo gratis.

Se ejecuta en un puerto abierto en tu computadora, lo que significa que cualquiera puede conectarse a él y comunicarse por Internet.

Diagrama que muestra una conexión a una computadora a través de un puerto.
Las computadoras se conectan entre sí por "puertos". Bitcoin usa el puerto 8333 por defecto.

Cuando ejecutas Bitcoin, usa puertos para conectarse a otras computadoras que ejecutan el mismo programa. Así que, cuando hay muchas personas ejecutando Bitcoin, terminas con una red de computadoras conectadas y comunicándose.

Lo bueno de Bitcoin es que puedes escribir tu propio programa básico para conectarte a un nodo, si quieres. Solo necesitas saber hablar su idioma.

En esta guía te mostraré cómo conectarte a un nodo Bitcoin usando Ruby. Ruby es un lenguaje sencillo, así que deberías poder traducir el código al lenguaje que prefieras.

1. Conexión

Diagrama que muestra una conexión a un nodo Bitcoin por el puerto 8333.

Primero, dos datos rápidos que necesitas saber sobre el programa Bitcoin:

Así que todo lo que necesitas para conectarte a un nodo Bitcoin es la dirección IP de la computadora donde se está ejecutando y la capacidad de hacer conexiones TCP desde tu lenguaje de programación.

TCP = Transmission Control Protocol. Es una forma de que dos computadoras se comuniquen por Internet (una saluda primero, la otra responde con otro saludo, etc.). Tu computadora usó TCP cuando descargó esta página. No necesitas saber cómo funcionan estos protocolos: solo necesitas saber que Bitcoin usa TCP.

2. Mensajes

Un "mensaje" es solo un bloque de datos estructurados que los nodos Bitcoin se envían entre sí por la red. Todos tienen el mismo formato:

Diagrama de un mensaje de red siendo enviado de un nodo Bitcoin a otro.

Aquí tienes un ejemplo de cómo se ve un mensaje Bitcoin real:

Cabecera: F9BEB4D976657273696F6E0000000000550000002C2F86F3
Payload:   7E1101000000000000000000C515CF6100000000000000000000000000000000000000000000FFFF2E13894A208D000000000000000000000000000000000000FFFF7F000001208D00000000000000000000000000

Cuando construyes un mensaje para enviarlo a otro nodo, básicamente tomas datos legibles por humanos (como números y texto) y los conviertes en bytes legibles por la computadora, que pueden enviarse por la red de forma más eficiente.

Así que el truco para enviar mensajes en Bitcoin es colocar todos los datos en el formato correcto. Voy a usar un mensaje del tipo "version" como primer ejemplo, porque es el primer mensaje que quieres enviar a un nodo Bitcoin después de conectarte a él.

Version

Cabecera (Header)

La cabecera contiene un resumen del mensaje, y su estructura es la misma para cualquier mensaje en el protocolo Bitcoin:

Cabecera (mensaje version)
┌─────────────┬──────────────┬───────────────┬──────────┬─────────────────────────────────────┐
│ Nombre      │ Datos Ex.    │ Formato       │ Tamaño   │ Bytes                               │
├─────────────┼──────────────┼───────────────┼──────────┼─────────────────────────────────────┤
│ Magic Bytes │              │ bytes         │        4 │ F9 BE B4 D9                         │
│ Comando     │ "version"    │ bytes ascii   │       12 │ 76 65 72 73 69 6F 6E 00 00 00 00 00 │
│ Tamaño      │ 85           │ little-endian │        4 │ 55 00 00 00                         │
│ Checksum    │              │ bytes         │        4 │ F7 63 9C 60                         │
└─────────────┴──────────────┴───────────────┴──────────┴─────────────────────────────────────┘

Payload

El payload contiene el contenido principal del mensaje. Los distintos tipos de mensajes tienen estructuras diferentes para sus payloads. Aquí está el payload de un mensaje "version":

Payload (mensaje version):
┌───────────────────┬────────────────┬──────────────────────────┬─────────┐
│ Nombre            │ Datos Ejemplo  │ Formato                  │ Tamaño  │
├───────────────────┼────────────────┼──────────────────────────┼─────────┤
│ Versión Protocolo │ 70014          │ little-endian            │       4 │
│ Services          │ 0              │ bit field, little-endian │       8 │
│ Tiempo            │ 1640961477     │ little-endian            │       8 │
│ Services (Remoto) │ 0              │ bit field, little-endian │       8 │
│ IP Remoto         │ 46.19.137.74   │ ipv6, big-endian         │      16 │
│ Puerto Remoto     │ 8333           │ big-endian               │       2 │
│ Services (Local)  │ 0              │ bit field, little-endian │       8 │
│ IP Local          │ 127.0.0.1      │ ipv6, big-endian         │      16 │
│ Puerto Local      │ 8333           │ big-endian               │       2 │
│ Nonce             │ 0              │ little-endian            │       8 │
│ User Agent        │ ""             │ compact size, ascii      │ compact │
│ Último Bloque     │ 0              │ little-endian            │       4 │
└───────────────────┴────────────────┴──────────────────────────┴─────────┘

Un mensaje "version" es uno de los más complejos de Bitcoin, pero solo porque contiene mucha información. Es un buen punto de partida, porque si puedes construir un mensaje "version", puedes construir cualquier mensaje del protocolo Bitcoin. Resumen de los campos:

Código (Ruby) — construyendo el mensaje version

Ejemplo en Ruby para construir un mensaje version (con funciones auxiliares para formatear los datos):

require 'digest' # necesario para crear checksums

# Funciones auxiliares para dejar los datos en el formato correcto para los mensajes
def hexadecimal(number)
    return number.to_s(16)
end

def size(data, size)
    return data.rjust(size*2, '0') # rellena a la izquierda con ceros hasta un nº de bytes (2 chars hex = 1 byte)
end

def reversebytes(bytes)
    return bytes.scan(/../).reverse.join() # toma cada 2 chars (1 byte), invierte y vuelve a unir
end

def ascii2hex(string)
    # Convierte cada carácter de la cadena a su representación en bytes hexadecimales
    bytes = string.each_byte.map {|c| c.to_s(16) }.join()

    # Rellena hasta 12 bytes (manteniendo los bytes de la cadena ASCII a la izquierda)
    return bytes.ljust(24, '0')
end

def checksum(bytes)
    # Hace hash de los datos dos veces
    hash = Digest::SHA256.digest(Digest::SHA256.digest([bytes].pack("H*"))).unpack("H*")[0]

    # Devuelve los primeros 4 bytes (8 caracteres)
    return hash[0...8]
end

# Crea el payload de un mensaje version
payload  = reversebytes(size(hexadecimal(70014), 4))         # versión del protocolo
payload += reversebytes(size(hexadecimal(0), 8))             # servicios (ej.: 1<<3 | 1<<2 | 1<<0)
payload += reversebytes(size(hexadecimal(1640961477), 8))    # tiempo
payload += reversebytes(size(hexadecimal(0), 8))             # servicios del nodo remoto
payload += "00000000000000000000ffff2e13894a"                # ipv6 del nodo remoto
payload += size(hexadecimal(8333), 2)                        # puerto del nodo remoto
payload += reversebytes(size(hexadecimal(0), 8))             # servicios del nodo local
payload += "00000000000000000000ffff7f000001"                # ipv6 del nodo local
payload += size(hexadecimal(8333), 2)                        # puerto del nodo local
payload += reversebytes(size(hexadecimal(0), 8))             # nonce
payload += "00"                                              # user agent (compact_size + bytes ascii)
payload += reversebytes(size(hexadecimal(0), 4))             # último bloque

# Crea la cabecera del mensaje
magic_bytes = 'f9beb4d9'
command     = ascii2hex('version')                                 # 76 65 72 73 69 6F 6E 00 00 00 00 00
size        = reversebytes(size(hexadecimal(payload.length/2), 4)) # 55 00 00 00
checksum    = checksum(payload)
header      = magic_bytes + command + size + checksum

# Junta cabecera y payload
message = header + payload

Y así es como queda nuestro mensaje final "version" como una cadena de bytes hexadecimales:

F9BEB4D976657273696F6E0000000000550000002C2F86F37E1101000000000000000000C515CF6100000000000000000000000000000000000000000000FFFF2E13894A208D000000000000000000000000000000000000FFFF7F000001208D00000000000000000000000000

3. Handshake

El handshake es el proceso que establece la comunicación entre dos dispositivos de red.

Antes de empezar a recibir datos, necesitamos hacer un "handshake". Ese handshake es solo una secuencia de mensajes que intercambiamos para empezar.

Diagrama de la secuencia de mensajes en el handshake del protocolo Bitcoin.

El handshake es básicamente un proceso de 2 pasos:

  1. Iniciamos la comunicación enviando nuestro mensaje "version", y ellos responden con su propio mensaje "version".
  2. Luego envían un mensaje "verack" (version acknowledgement) confirmando que recibieron nuestro mensaje version, y nosotros terminamos enviando un mensaje "verack" de vuelta.

El orden de los mensajes en el handshake es importante. Si te equivocas de orden, el handshake falla y el otro nodo rechaza tu conexión. Siempre puedes intentarlo de nuevo, pero si te equivocas demasiadas veces, podrías ser baneado temporalmente (solo conecta a otro nodo mientras tanto).

Verack

"Verack" es una cabecera de mensaje simple, sin payload:

Mensaje Verack:
Magic Bytes  4 bytes  F9 BE B4 D9
Comando     12 bytes  "verack" -> 76 65 72 61 63 6B 00 00 00 00 00 00
Tamaño      4 bytes  00 00 00 00  (payload de tamaño 0)
Checksum    4 bytes  5D F6 E0 E2

Hexadecimal: F9BEB4D976657261636B000000000000000000005DF6E0E2

Un mensaje "verack" siempre es el mismo.

4. Recepción de Mensajes

El nodo al que acabas de conectarte enviará continuamente nuevos mensajes después del handshake. Así que, para seguir recibiendo, basta con seguir leyendo del socket en un bucle.

Diagrama que muestra los mensajes que un nodo recibe justo después de conectarse a otro nodo.

5. Solicitud de Transacciones y Bloques

Un nodo no te enviará abiertamente todas las nuevas transacciones y bloques que recibió. En su lugar, para ahorrar banda, te envía una lista de hashes de las transacciones y bloques más recientes en mensajes "inv" (inventory/inventario).

Luego puedes responder a esos mensajes "inv" indicando las transacciones y bloques específicos que quieres, con mensajes "getdata". Después de eso, el nodo te envía las transacciones y bloques completos en mensajes "tx" y "block" posteriores.

Diagrama que muestra la secuencia de mensajes para pedir transacciones y bloques en el protocolo Bitcoin.

Inv

El payload de un mensaje "inv" contiene un Contador (compact size) seguido de uno o más vectores de Inventario. Cada elemento de inventario es un Tipo (4 bytes little-endian) + un Hash (32 bytes):

Tipos:
01 00 00 00 = MSG_TX     (Hash de Transacción)
02 00 00 00 = MSG_BLOCK  (Hash de Bloque)

Getdata

El mensaje "getdata" con el que respondes tiene exactamente la misma estructura que el mensaje "inv". Así que, si quieres todas las transacciones y bloques del "inv", puedes responder con el mismo payload. Si no quieres todos, construye un payload solo con los hashes que deseas.

Aquí tienes una lista completa de los mensajes que los nodos Bitcoin pueden intercambiar.

6. Mantener la Conexión

Una última cosa: el nodo al que te conectaste ocasionalmente envía mensajes "ping" para comprobar si sigues ahí. Así que, si quieres mantener la conexión viva, necesitas responder con mensajes "pong" a tiempo.

Diagrama que muestra la secuencia de mensajes para mantener una conexión viva mediante ping y pong.

A partir de la versión de protocolo 60001, cada mensaje "ping" contiene un número aleatorio de 8 bytes (Nonce) como payload. Tu mensaje "pong" de respuesta solo necesita contener ese mismo número en el payload.

7. Encontrar Nodos

¿No sabes dónde encontrar un nodo al que conectarte? Aquí tienes algunos lugares para probar:

Puedes hacer una consulta DNS a un DNS seed en la línea de comandos con: nslookup seed.bitcoin.sipa.be.

8. Resumen

Conectarte a un nodo desde cero es una forma buena de empezar a programar en Bitcoin. Te permite ver cómo se comunican los nodos y te da acceso en vivo a las transacciones y bloques más recientes de la red.

Puedes conectarte a un nodo en prácticamente cualquier lenguaje. Solo necesitas poder hacer conexiones TCP y tener la IP y el puerto de una computadora que esté ejecutando un nodo (localmente, IP 127.0.0.1 y puerto 8333).

La parte más complicada con diferencia es descubrir cómo construir los mensajes. Necesitas poner todos los bytes en el orden correcto, porque si te equivocas en un byte, el nodo no te va a entender. Pero, después de enviar correctamente el primer mensaje, todos los demás tipos de mensajes se vuelven mucho más fáciles.

Obtener mi primera transacción bruta de un nodo Bitcoin real, usando un script que escribí desde cero, fue uno de los logros más satisfactorios de mi carrera como programador.

Buena suerte.

Programa completo (Ruby)

El programa completo de este tutorial: se conecta a un nodo, hace el handshake y sigue leyendo mensajes (respondiendo a ping e inv). Los comentarios del código están en inglés (como en el original).

# Los sockets están en la librería estándar de Ruby
require 'socket'

# Abre una conexión TCP a una IP y un puerto
socket = TCPSocket.open("127.0.0.1", 8333) # computadora local = 127.0.0.1

require 'digest' # necesario para crear checksums

# Funciones útiles para obtener datos en el formato correcto para los mensajes
def hexadecimal(number)
    return number.to_s(16)
end

def size(data, size)
    return data.rjust(size*2, '0') # rellena a la izquierda con ceros hasta un nº específico de bytes (2 chars hex = 1 byte)
end

def reversebytes(bytes)
    return bytes.scan(/../).reverse.join() # toma cada 2 caracteres (1 byte) en un array, invierte el array y vuelve a unir
end

def ascii2hex(string)
    # Convierte cada carácter de la cadena a su representación hexadecimal en bytes
    bytes = string.each_byte.map {|c| c.to_s(16) }.join()

    # Rellena hasta 12 bytes (manteniendo los bytes de la cadena ASCII a la izquierda)
    return bytes.ljust(24, '0')
end

def checksum(bytes)
    # Hace hash de los datos dos veces
    hash = Digest::SHA256.digest(Digest::SHA256.digest([bytes].pack("H*"))).unpack("H*")[0]

    # Devuelve los primeros 4 bytes (8 caracteres)
    return hash[0...8]
end

# Crea el payload de un mensaje version
payload  = reversebytes(size(hexadecimal(70014), 4))         # versión del protocolo
payload += reversebytes(size(hexadecimal(0), 8))             # servicios, por ejemplo (1<<3 | 1<<2 | 1<<0)
payload += reversebytes(size(hexadecimal(1640961477), 8))    # hora
payload += reversebytes(size(hexadecimal(0), 8))             # servicios del nodo remoto
payload += "00000000000000000000ffff2e13894a"                # ipv6 del nodo remoto (https://dnschecker.org/ipv4-to-ipv6.php)
payload += size(hexadecimal(8333), 2)                        # puerto del nodo remoto
payload += reversebytes(size(hexadecimal(0), 8))             # servicios del nodo local
payload += "00000000000000000000ffff7f000001"                # ipv6 del nodo local
payload += size(hexadecimal(8333), 2)                        # puerto del nodo local
payload += reversebytes(size(hexadecimal(0), 8))             # nonce
payload += "00"                                              # user agent (compact_size, seguido de bytes ascii)
payload += reversebytes(size(hexadecimal(0), 4))             # último bloque

# Crea la cabecera del mensaje
magic_bytes = 'f9beb4d9'
command     = ascii2hex('version')                                 # 76 65 72 73 69 6F 6E 00 00 00 00 00
size        = reversebytes(size(hexadecimal(payload.length/2), 4)) # 55 00 00 00
checksum    = checksum(payload)
header      = magic_bytes + command + size + checksum

# Combina la cabecera y el payload
message = header + payload

# 1. Enviar mensaje Version

# Prepara el mensaje version
version = message

# Escribe el mensaje en el socket (el protocolo envía y recibe mensajes en bytes crudos)
socket.write [version].pack("H*")
puts "version->"
puts version
puts


# 2. Recibir mensaje Version

# Lee la respuesta de la cabecera del mensaje desde el socket
magic_bytes = socket.read(4)
command     = socket.read(12)
size        = socket.read(4)
checksum    = socket.read(4)

# Muestra la cabecera del mensaje
puts "<-version"
puts "magic_bytes: " + magic_bytes.unpack("H*").join  # convierte bytes crudos a caracteres hexadecimales
puts "command:     " + command.to_s                   # to_s convierte automáticamente bytes crudos a ASCII
puts "size:        " + size.unpack("V").join          # V = entero sin signo de 32 bits, little-endian
puts "checksum:    " + checksum.unpack("H*").join

# Lee el payload del mensaje
size = size.unpack("V").join.to_i
payload = socket.read(size)

# Muestra el payload
puts "payload:     " + payload.unpack("H*").join
puts


# 3. Recibir mensaje Verack (verack = version acknowledged)

# Lee la respuesta de la cabecera del mensaje desde el socket
magic_bytes = socket.read(4)
command     = socket.read(12)
size        = socket.read(4)
checksum    = socket.read(4)

# Muestra la cabecera del mensaje
puts "<-verack"
puts "magic_bytes: " + magic_bytes.unpack("H*").join  # convierte bytes crudos a caracteres hexadecimales
puts "command:     " + command.to_s                   # to_s convierte automáticamente bytes crudos a ASCII
puts "size:        " + size.unpack("V").join          # V = entero sin signo de 32 bits, little-endian
puts "checksum:    " + checksum.unpack("H*").join

# Lee el payload del mensaje (no debería haber ninguno)
size = size.unpack("V").join.to_i
payload = socket.read(size)

# Muestra el payload del mensaje (no debería haber ninguno)
puts "payload:     " + payload.unpack("H*").join
puts

# 4. Enviar mensaje Verack

# Crea el mensaje verack
payload     = '' # verack no tiene payload, solo una cabecera de mensaje
magic_bytes = 'f9beb4d9'
command     = ascii2hex('verack')
size        = reversebytes(size(hexadecimal(payload.size/2), 4))
checksum    = checksum(payload)
verack      = magic_bytes + command + size + checksum + payload

# Escribe el mensaje en el socket
socket.write [verack].pack("H*")
puts "verack->"
puts "magic_bytes: " + magic_bytes
puts "command:     " + 'verack'
puts "size:        " + size.to_i(16).to_s
puts "checksum:    " + checksum
puts "payload:     " + payload
puts

# Sigue leyendo mensajes
loop do

    # Crea un buffer vacío para ayudarnos a encontrar el siguiente flujo de bytes mágicos (el inicio de un nuevo mensaje)
    buffer = ''

    # Sigue leyendo bytes del socket
    loop do

        # Lee un byte a la vez
        byte = socket.read(1)

        # Verifica que no nos hayan desconectado del nodo.
        if byte.nil?
            puts "Leí un byte nulo desde el socket. Parece que el nodo remoto se desconectó de nosotros. Probablemente fallamos el handshake demasiadas veces o no respondimos a suficientes pings. No pasa nada, prueba conectarte a otro nodo por ahora."
            exit
        end

        # Agrega cada byte al buffer temporal
        buffer += byte.unpack("H*").join unless byte.nil? # no hagas nada si por alguna razón obtuvimos un byte nulo

        # Revisa el buffer cuando alcance 4 bytes
        if (buffer.size == 8) # 8 caracteres hexadecimales = 4 bytes

            # Comprueba si el buffer coincide con los bytes mágicos
            if (buffer == 'f9beb4d9')

                # Si tenemos los bytes mágicos que buscamos, leemos el mensaje completo del socket
                command     = socket.read(12).to_s.delete("\x00")  # convierte a ASCII y elimina bytes vacíos
                size        = socket.read(4).unpack("V").join.to_i   # convierte a entero
                checksum    = socket.read(4).unpack("H*").join       # convierte a string hexadecimal de bytes
                payload     = socket.read(size).unpack("H*").join    # usa el tamaño de la cabecera para leer el payload, luego convierte a string hexadecimal

                # Imprime el mensaje
                puts "<-#{command}"
                puts "magic_bytes: " + buffer
                puts "command:     " + command
                puts "size:        " + size.to_s
                puts "checksum:    " + checksum
                puts "payload:     " + payload
                puts

                # Responde a todos los mensajes inv con mensajes getdata
                if command == "inv"

                    # Define el nuevo nombre del comando
                    command = "getdata"

                    # Usa el mismo payload que recibimos del mensaje inv
                    payload = payload

                    # Crea el mensaje
                    magic_bytes = 'f9beb4d9'
                    command_hex = ascii2hex(command)
                    size        = reversebytes(size(hexadecimal(payload.size/2), 4))
                    checksum    = checksum(payload)
                    message     = magic_bytes + command_hex + size + checksum + payload

                    # Imprime la cabecera y el payload del mensaje
                    puts "#{command}->"
                    puts "magic_bytes: " + magic_bytes
                    puts "command:     " + command
                    puts "size:        " + (payload.size/2).to_s
                    puts "checksum:    " + checksum
                    puts "payload:     " + payload
                    puts

                    # Envía el mensaje (convierte primero de cadena hexadecimal a bytes crudos)
                    socket.write [message].pack("H*")

                end

                # Responde a todos los mensajes ping con mensajes pong
                if command == "ping"

                    # Define el nuevo nombre del comando
                    command = "pong"

                    # Usa el mismo payload que recibimos del mensaje ping
                    payload = payload

                    # Crea el mensaje
                    magic_bytes = 'f9beb4d9'
                    command_hex = ascii2hex(command)
                    size        = reversebytes(size(hexadecimal(payload.size/2), 4))
                    checksum    = checksum(payload)
                    message     = magic_bytes + command_hex + size + checksum + payload

                    # Imprime la cabecera y el payload del mensaje
                    puts "#{command}->"
                    puts "magic_bytes: " + magic_bytes
                    puts "command:     " + command
                    puts "size:        " + (payload.size/2).to_s
                    puts "checksum:    " + checksum
                    puts "payload:     " + payload
                    puts

                    # Envía el mensaje (convierte primero de cadena hexadecimal a bytes crudos)
                    socket.write [message].pack("H*")

                end

                # Sale del bucle de lectura de un único mensaje
                break

            end

            # Reinicia el buffer y sigue buscando una secuencia de bytes mágicos
            buffer = ''

        end

    end

end