Notas de campo de Santala Research tras operar esta configuración en las testnets sapphire-1 y pearl-1 (agosto a septiembre de 2026) y validar el fork de Horcrux de gno.land contra un gnoland v1.5.0 sin modificar en onyx-1 (septiembre de 2026). Escritas para operadores que ya saben ejecutar un nodo gnoland normal. Todo lo que hay aquí es lo que realmente ejecutamos; los errores también son nuestros.

Por qué molestarse

Un validador normal guarda priv_validator_key.json en una sola máquina. Quien consiga esa máquina puede firmar como tú; y si alguna vez arrancas una segunda copia “por si acaso”, firmas doble. Horcrux resuelve ambas cosas: la clave se divide en 3 fragmentos en 3 máquinas, 2 cualesquiera deben ponerse de acuerdo para firmar, ninguna máquina tiene nunca la clave completa, y los cofirmantes comparten un estado de firma mediante Raft, de modo que un nodo desactualizado no puede hacerles firmar una altura inferior. Lo ejecutamos con el tmkms listener integrado de gno.land, que habla el protocolo privval upstream que Horcrux espera.

La arquitectura que usamos

                  internet
                     │
              ┌──────┴──────┐
              │  sentry(s)  │  public p2p, private_peer_ids = validator
              └──────┬──────┘
          WireGuard  │  (validator: pex=false, persistent_peers = sentries only)
              ┌──────┴──────┐  tmkms_listener on the WireGuard address, port 5556
              │  validator  │◄────────────── cosigner #1 (same host or another)
              └─────────────┘◄──── WireGuard ── cosigner #2
                              ◄──── WireGuard ── cosigner #3
  • Todo el tráfico entre cofirmantes (Raft, gRPC) y entre cofirmante y validador (firmante) va por una malla WireGuard privada; el cortafuegos del host permite esos puertos solo en la interfaz wg0.
  • Los cofirmantes son diminutos (unos 20 MB de RAM, CPU insignificante). Pueden compartir host con un sentry o con una máquina de monitorización; un fragmento solo no puede firmar.
  • Coloca el validador a menos de unos 15 ms de al menos dos cofirmantes. Cada voto es un viaje de ida y vuelta por el firmante umbral. Medimos unos 350 ms por voto con el nodo firmante en Asia y los cofirmantes en Europa, y unos 50 ms una vez colocados juntos. Esa diferencia decide si llegas al commit en una ronda rápida.

0. Requisitos previos: usa el fork de Horcrux de gno.land, no el upstream

  • Usa gnolang/horcrux (etiqueta v3.3.2-gno.4 o posterior), un fork endurecido de la v3.3.2 archivada de strangelove, mantenido por el equipo central de gno. La v3.3.2 upstream tiene dos problemas de compatibilidad con el tmkms listener de gno.land y, peor aún, fallos de seguridad críticos que el fork corrige: un fallo en el manejo de nonces que podía exponer material de clave, una vía residual de doble firma y una superficie de administración del clúster sin autenticar. No ejecutes el upstream en un validador.
  • Tres características del fork hacen que un gnoland sin modificar (v1.5.0 o posterior) funcione sin ningún parche:
    1. horcrux create-conn-key da a cada cofirmante una identidad de conexión persistente, de modo que puedes fijar su clave pública hex en el allowed_kms_pubkeys del nodo. El upstream regenera la identidad en cada arranque, lo que no se puede fijar.
    2. thresholdMode.leaderOnlyChainNodeConnections: true: solo el líder de Raft marca al nodo. El listener de gno.land tiene un único hueco de firmante y se desestabiliza si marcan todos los cofirmantes.
    3. Una respuesta de firma conforme a la especificación (el voto completo devuelto), que gno.land valida de forma estricta.
  • Compila el fork con cgo activado (CGO_ENABLED=1, necesita gcc). Las claves ECIES de los cofirmantes usan secp256k1, y una compilación estática falla con ScalarMult is not available when secp256k1 is built without cgo.
git clone --branch v3.3.2-gno.4 --depth 1 https://github.com/gnolang/horcrux.git && cd horcrux
CGO_ENABLED=1 go build -o /usr/local/bin/horcrux ./cmd/horcrux

gnoland en sí: compílalo desde la etiqueta de la release exactamente como dice la documentación de la red, anota el SHA-256, sin parches.

1. Crear la clave y los fragmentos (una vez, en el host del validador)

gnoland secrets init -data-dir ./gnoland-data   # priv_validator_key.json, priv_validator_state.json, node_key.json

Trampa (v1.5.0): secrets init escribe los tres archivos en la raíz del data-dir, mientras que el nodo los lee desde data-dir/secrets/. Muévelos:

mkdir -p gnoland-data/secrets && mv gnoland-data/{priv_validator_key,priv_validator_state,node_key}.json gnoland-data/secrets/
gnoland secrets get validator_key -data-dir gnoland-data/secrets   # point at secrets/, not the data-dir, or it panics

Haz copia de seguridad de la clave ahora, cifrada y fuera del host (usamos openssl enc -aes-256-cbc -pbkdf2, con la frase de paso en papel; prueba el descifrado en otra máquina).

Trampa: Horcrux lee el formato de clave de CometBFT, no el de gno.land ("address": "g1…", "@type": "/tm.PubKeyEd25519"), así que create-ed25519-shards falla con encoding/hex: invalid byte: 'g'. Conviértela primero a un archivo temporal en formato CometBFT. Los bytes de la clave son idénticos; solo cambia el envoltorio:

python3 - <<'PY'
import json,base64,hashlib
d=json.load(open("gnoland-data/secrets/priv_validator_key.json"))
pub=base64.b64decode(d["pub_key"]["value"])
json.dump({"address":hashlib.sha256(pub).digest()[:20].hex().upper(),
           "pub_key":{"type":"tendermint/PubKeyEd25519","value":d["pub_key"]["value"]},
           "priv_key":{"type":"tendermint/PrivKeyEd25519","value":d["priv_key"]["value"]}},
          open("pvk-cometbft.json","w"))
PY
chmod 600 pvk-cometbft.json
horcrux create-ed25519-shards --chain-id <chain-id> --key-file pvk-cometbft.json --threshold 2 --shards 3 --out ./shards
horcrux create-ecies-shards --shards 3 --out ./ecies      # one ecies_keys.json per cosigner
shred -u pvk-cometbft.json

Obtienes shards/cosigner1..3/<chain-id>_shard.json. Copia cada uno a su cofirmante por SSH (nunca por chat ni correo) y después borra la clave completa de todos los hosts:

shred -u gnoland-data/secrets/priv_validator_key.json

El nodo ya no la necesita: con el tmkms listener activado nunca lee una clave local.

Los archivos de fragmento llevan el nombre de la cadena (<chain-id>_shard.json). Pasar a una nueva testnet con la misma clave es copiar un archivo, más un borrado de Raft (ver sección 5).

2. Configuración de los cofirmantes (cada uno de los 3 hosts)

En cada host cofirmante, como el usuario del cofirmante:

horcrux create-conn-key --home ~/.horcrux     # prints a 64-hex public key; collect all three for the node's allowlist

Después, ~/.horcrux/config.yaml:

signMode: threshold
thresholdMode:
  threshold: 2
  cosigners:
  - shardID: 1
    p2pAddr: tcp://<wg-ip-of-cosigner-1>:2222
  - shardID: 2
    p2pAddr: tcp://<wg-ip-of-cosigner-2>:2223
  - shardID: 3
    p2pAddr: tcp://<wg-ip-of-cosigner-3>:2224
  grpcTimeout: 2000ms
  raftTimeout: 2000ms
  leaderOnlyChainNodeConnections: true     # required for gno.land
chainNodes:
- privValAddr: tcp://<wg-ip-of-validator>:5556
connKeyFile: conn_key.json                 # the persistent identity from create-conn-key

Además, por host: su propio <chain-id>_shard.json y ecies_keys.json. Los permisos importan: el directorio home y el directorio state/ deben ser 0700. Un directorio 0600 hace que el cofirmante falle su propia comprobación del estado de firma, y el nodo reporta remote signer error … checking file existence. Ejecútalo como usuario dedicado bajo systemd con Restart=always y un límite de memoria.

Cortafuegos: permite 2222–2224 (Raft/gRPC) y 5556 (firmante) solo en wg0. Nada más en la interfaz pública salvo SSH y el UDP de WireGuard.

3. Configuración del validador (tmkms listener)

En config.toml:

[consensus.priv_validator.tmkms_listener]
listen_addr = "tcp://<wg-ip-of-validator>:5556"   # WireGuard address only
chain_id = "<chain-id>"
allowed_kms_pubkeys = ["<hex-conn-pubkey-cosigner-1>", "<hex-conn-pubkey-cosigner-2>", "<hex-conn-pubkey-cosigner-3>"]
protocol_version = "v0.34"

Establécelos con gnoland config set en este orden, primero chain_id y allowed_kms_pubkeys y por último listen_addr, o la validación rechaza el estado intermedio.

Arranca el nodo y después arranca los tres cofirmantes a la vez. Los seguidores registran Not the cluster leader, deferring connection to chain node; el líder registra Connected to Sentry; el nodo obtiene su clave pública de validador a través del firmante y, una vez en el valset, registra Signed and pushed vote en cada ronda. Ya puedes borrar el priv_validator_key.json local, el nodo ya no lo lee. Comprueba /status en el nodo: validator_info.address debe ser la dirección de tu validador aunque el host no tenga ningún archivo de clave.

4. Verifica en la cadena, no en los logs

Los logs dicen “firmado”; solo la cadena dice “contado”. Comprueba que la dirección de tu validador aparece en last_commit.precommits de los bloques recientes:

for h in $(seq $((TIP-12)) $((TIP-1))); do
  curl -s "$RPC/block?height=$h" | jq -r --arg a "$ADDR" \
    '[.result.block.last_commit.precommits[]? | select(. != null) | .validator_address] | index($a) != null'
done

En pearl-1 así descubrimos que un nodo que reportaba 12 eventos de firma por minuto estaba colocando 0 de 12 en la cadena. Iba un bloque por detrás y hacía precommit de nil. Nada en sus propios logs lo decía.

5. Lo que nos mordió (por orden de dolor)

  1. Raft recuerda direcciones y chain-ids. Cambiar la dirección de un cofirmante, pasar a una nueva cadena o renombrar un archivo de fragmento sin borrar ~/.horcrux/raft/ en todos los cofirmantes deja al clúster discutiendo con fantasmas (“Error loading sign state during raft replication”). Procedimiento: parar los tres, rm -rf ~/.horcrux/raft en cada uno, corregir las configuraciones, arrancar los tres a la vez.
  2. Dos nodos firmantes activos se hacen más lentos entre sí, no más seguros. Probamos un segundo gnoland apuntando a los mismos cofirmantes por redundancia. Horcrux sí evita la doble firma, pero los dos nodos compiten y producen errores constantes de step regression, añadiendo latencia. Usa en su lugar un standby frío (datos sincronizados, listener desactivado) y cambia a mano.
  3. Nunca uses el puerto del firmante como sonda de vida. Nuestras alertas hacían una conexión TCP a 5556 cada minuto; el listener acepta una conexión a la vez, la cola de accept se llenó hasta 4096 y recibimos falsas alertas de “OFFLINE” durante días. Sondea p2p o WireGuard en su lugar.
  4. Un validador nunca puede estar más al día que los pares que lo alimentan. Los sentries en vCPU compartida ejecutaban los bloques pesados más despacio que nuestro validador, así que el validador se quedaba un bloque por detrás y perdía todos los commits bajo carga, con el 92% de un núcleo ocioso. Dimensiona los sentries al menos tan rápidos como el validador, o mantén unos pocos pares persistentes solo salientes hacia los nodos centrales de la red como red de seguridad.
  5. Los sentries dejan fuera a su propio validador cuando su tabla de entrada está llena. Deja margen en max_num_inbound_peers en los sentries e incluye el validador en los persistent_peers del sentry para que sea el sentry quien marque al validador (las conexiones iniciadas por el sentry no dependen de que haya huecos de entrada libres).
  6. Las imágenes del proveedor pueden hacerte daño. Una imagen estándar de hosting ejecutaba echo 1 > /proc/sys/vm/drop_caches y fstrim desde /etc/cron.hourly. Perdíamos bloques en el minuto 17 de cada hora hasta que lo encontramos. Audita el cron en cada host nuevo.
  7. La geografía es latencia. Ver “Por qué molestarse”: mantén el nodo firmante junto a al menos dos cofirmantes.
  8. Nivel de log. En info un sentry público escribe millones de líneas por hora; la presión de GC de un validador venía sobre todo del logging y del gossip entre pares, no de la VM. Usa warn en los sentries y perfila antes de ajustar.

6. Failover, de la forma segura

  1. Declara el incidente; las alertas siguen activas.
  2. Demuestra que el nodo antiguo está muerto, no inalcanzable: para gnoland y su cofirmante, o apaga el host desde la consola del proveedor. Confirma que no hay ningún precommit tuyo en los siguientes commits ni ninguna conexión de firmante en los cofirmantes restantes.
  3. El estado de firma compartido en Raft de los cofirmantes ya guarda la última altura, ronda y paso firmados; en el nuevo nodo, traslada priv_validator_state.json o escribe uno con la altura actual.
  4. Solo ahora apunta el chainNodes de los cofirmantes al listener del nuevo nodo y arráncalo.
  5. Verifica /status y la comprobación en cadena de la sección 4.

El nuestro de verdad (una migración de host el 16 de septiembre de 2026) duró unos tres minutos sin firmar y sin doble firma.

7. Notas de monitorización específicas de gno.land

gno.land no expone un endpoint /metrics al estilo CometBFT. El nodo envía OTLP (sección [telemetry]) a un colector; las métricas de bloques perdidos y del conjunto de validadores vienen de herramientas basadas en RPC como samouraiworld/gnomonitoring (exportador Prometheus y alertas) y gnoverse/gnockpit (panel del valset en vivo). Alerta sobre tus propios fallos (bloques donde al menos dos tercios del conjunto firmaron y tú no), no sobre caídas de toda la red.

Preguntas o correcciones: búscanos en el Discord de gno.land (Santala-Research).

Sigue leyendo: por qué una sola clave en una sola máquina es el punto débil de cualquier configuración se explica desde cero en Carteras, claves y frases semilla, y cómo encajan validadores y finalidad en Prueba de trabajo vs prueba de participación. Nuestras investigaciones largas viven en Investigación.

Preguntas frecuentes

¿De qué protege realmente Horcrux? De dos cosas: del robo de la clave, porque ninguna máquina tiene nunca la clave completa del validador, y de la doble firma accidental, porque los cofirmantes mantienen un estado de firma compartido en Raft y se niegan a firmar una altura, ronda o paso que ya han firmado.

¿Necesito parchear gnoland para usar Horcrux? No. Usa el fork del equipo central de gno, gnolang/horcrux v3.3.2-gno.4 o posterior, con un gnoland v1.5.0 o posterior sin modificar. El fork añade una clave de conexión persistente que puedes fijar en allowed_kms_pubkeys, un modo de conexión solo desde el líder y una respuesta de firma conforme a la especificación, que juntos eliminan la necesidad de cambios en el nodo.

¿Por qué no ejecutar el Horcrux v3.3.2 upstream de strangelove? Además de ser incompatible con el tmkms listener de gno.land, la v3.3.2 upstream está archivada y arrastra fallos de seguridad que el fork de gno.land corrige: un fallo en el manejo de nonces que podía exponer material de clave, una vía residual de doble firma y una superficie de administración del clúster sin autenticar.

¿Por qué create-ed25519-shards falla con invalid byte 'g'? Horcrux espera el formato de archivo de clave de CometBFT, mientras que gno.land escribe una dirección bech32 que empieza por g1 y un envoltorio de tipo distinto. Convierte primero el archivo al formato CometBFT, como se muestra en la sección 1; los bytes de la clave son idénticos.

¿Cómo sé que mi validador está firmando de verdad? No te fíes de los logs del firmante. Consulta los bloques recientes por RPC y comprueba que la dirección de tu validador aparece en last_commit.precommits. Un nodo puede registrar una firma correcta en cada ronda mientras sus votos nunca llegan a un commit.