Substrate: del runtime al consenso propio

Artículo 9 de 20

45%

completado al llegar aquí

Sistemas distribuidosNota de investigación14 min de lectura

Actualizar sin fork: set_code, versiones y migraciones

Cómo una cadena Substrate cambia su lógica escribiendo un Wasm nuevo en :code, qué comprueba set_code, por qué hay que subir spec_version y cómo se prueban las migraciones.

Ruta Substrate: 0/20

Un equipo cambia el tipo de un item de storage: los montos de un pallet pasan de u64 a u128 para admitir activos con más decimales. Compila, los tests unitarios pasan y la gobernanza aprueba la actualización. En el bloque siguiente, la primera llamada que lee un monto antiguo intenta decodificar ocho bytes como si fueran dieciséis y falla. El nodo no tiene la culpa: guarda bytes y nunca supo qué significaban. Faltó una migración que convirtiera los valores viejos al formato nuevo.

Este capítulo recorre cómo una cadena Substrate (Polkadot SDK) cambia su lógica sin que los operadores reinstalen el nodo, qué controles tiene ese proceso y dónde falla en la práctica.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Un origen con permiso, normalmente la gobernanza

Solo el origen Root, o lo que la cadena configure como tal, puede llamar a set_code.

Evidencia

Evento CodeUpdated y digest RuntimeEnvironmentUpdated

El bloque que cambia el código lo deja escrito en eventos y en la cabecera.

Estado

:code, spec_version y LastRuntimeUpgrade

El Wasm vive en el estado; la versión decide si corren las migraciones.

Por qué una cadena Substrate puede cambiar sin fork

La documentación del SDK parte de la definición de siempre: la lógica de una cadena es estado más función de transición. Antes de Substrate, cambiar esa función obligaba a los operadores a descargar software nuevo y reiniciar, es decir, a un fork. Las cadenas Substrate no lo necesitan y actualizan el runtime con un proceso llamado runtime upgrade. Eso es posible porque el runtime vive en el propio estado.

Ya viste las dos piezas. En el runtime en Wasm, el blob se guarda bajo la clave :code. En el estado, el nodo trata todo el estado como bytes opacos. Cambiar la lógica es, entonces, escribir otro valor bajo :code: una transición de estado más, sujeta a las mismas reglas de consenso que una transferencia.

La especificación fija la obligación del lado del nodo: el ejecutor debe cargar siempre el runtime más reciente del storage, tomando el del bloque padre al importar y el del mejor bloque al producir. Por eso todos los nodos cambian de runtime en el mismo bloque sin coordinarse: el cambio lo dicta el estado, no la fecha en que cada operador actualiza.

set_code: quién puede y qué se comprueba

La documentación lo resume así: un origen con los permisos necesarios, normalmente vía gobernanza, cambia el storage :code, en general llamando a set_code con el blob nuevo y programando la llamada con pallet_scheduler. En una red de desarrollo ese origen suele ser una cuenta sudo; en producción, un referéndum.

El código de frame_system muestra qué pasa dentro:

rust
// Fragmento de substrate/frame/system/src/lib.rs (polkadot-sdk)
#[pallet::weight((T::SystemWeightInfo::set_code(), DispatchClass::Operational))]
pub fn set_code(origin: OriginFor<T>, code: Vec<u8>) -> DispatchResultWithPostInfo {
    ensure_root(origin)?;
    Self::can_set_code(&code, true).into_result()?;
    T::OnSetCode::set_code(code)?;
    // consume the rest of the block to prevent further transactions
    Ok(Some(T::BlockWeights::get().max_block).into())
}

Cuatro detalles merecen nombre:

  • ensure_root: solo el origen Root. Qué cuenta, colectivo o referéndum se convierte en Root lo decide cada runtime.
  • can_set_code: lee la versión del Wasm nuevo y exige el mismo spec_name y una spec_version mayor. Si no, falla con InvalidSpecName o SpecVersionNeedsToIncrease.
  • Clase Operational: puede usar el bloque entero, como viste en pesos y comisiones.
  • Peso real igual al máximo del bloque: la llamada consume todo lo que queda para que no entren más transacciones detrás de ella.

Hay variantes. set_code_without_checks omite la comprobación de versión; el propio código advierte que, con una versión que no sube, las migraciones no se ejecutarán. Y existe una ruta en dos fases: authorize_upgrade autoriza solo el hash del código, y después cualquiera puede subir el blob con apply_authorized_upgrade. Sirve cuando mover el código entero por gobernanza o por un puente es incómodo.

RuntimeVersion: el número que todos miran

Cada runtime declara una RuntimeVersion. Los campos que importan aquí son spec_name, que identifica la cadena, y spec_version, que identifica la versión de la lógica. Olvidar subir spec_version es, según la guía de actualización, un error común: la cadena no reconoce el runtime como nuevo. Con set_code la llamada directamente falla; con la variante sin comprobaciones, el código cambia pero las migraciones no corren.

La versión también la usan los clientes. En extrinsics viste que CheckSpecVersion mete spec_version en lo que se firma. Una actualización invalida las firmas pendientes, y una billetera debe volver a leer la versión y la metadata cuando la cadena cambia de runtime.

Del bloque que cambia el código al primero que lo usa

El orden de los hechos en el tiempo es lo que más confunde. La figura lo recorre con el camino clásico, en el que set_code escribe directamente en :code.

Figura 1

Una actualización del runtime, bloque a bloque

Fuentes:

[2][4][5]
Una actualización del runtime, bloque a bloque
Cómo leer esta figura

Dos columnas de bloque, N y N+1, sobre una franja superior que representa al nodo con el mismo binario en ambos bloques. En el bloque N, ejecutado con el runtime v100: un extrinsic set_code con origen Root; can_set_code comprueba spec_name igual y spec_version mayor; se escribe :code; se emiten el evento CodeUpdated y el digest RuntimeEnvironmentUpdated; la llamada consume el resto del peso del bloque. Una flecha va del estado final de N al inicio de N+1 con la etiqueta el nodo carga :code del estado padre. En el bloque N+1, ejecutado con v101: initialize_block compara LastRuntimeUpgrade con la versión actual; como subió, corre on_runtime_upgrade con las migraciones; actualiza LastRuntimeUpgrade; luego on_initialize, inherentes y transacciones. Una nota indica que en runtimes con system_version 3 o mayor el Wasm espera en :pending_code y se aplica unos bloques después.

Visual original de TrautsLab. El bloque N todavía se ejecuta con el runtime viejo: es el que contiene set_code y escribe el Wasm nuevo. El bloque N+1 carga el runtime desde el estado de su padre, detecta que la versión cambió y corre las migraciones antes que cualquier otra cosa. El binario del nodo no cambió en ningún momento.

En el bloque N el runtime viejo ejecuta set_code, escribe el Wasm nuevo y deja dos rastros: el evento CodeUpdated y un ítem RuntimeEnvironmentUpdated en el digest de la cabecera, que avisa a quien sigue la cadena que el entorno cambió. En el bloque N+1 el nodo toma :code del estado del padre, de modo que ese bloque ya corre con la lógica nueva.

Lo primero que hace el runtime nuevo es comprobar si hubo actualización. frame_executive compara el valor guardado LastRuntimeUpgrade con la versión actual; si spec_version subió o spec_name cambió, ejecuta on_runtime_upgrade antes que on_initialize y guarda la versión nueva. Ese es el momento en que corren las migraciones.

Hay una variante reciente. En runtimes cuya system_version es 3 o mayor, frame_system no escribe en :code de inmediato: guarda el blob en :pending_code y lo copia a :code al finalizar un bloque posterior, con un contador de bloques. Cuántos bloques exactos pasan entre set_code y el primer bloque con el código nuevo en esa variante es un dato a validar en una red local con la versión del SDK que uses. Las parachains, además, difieren la aplicación hasta que la relay chain la acepta; se verá en el capítulo de Cumulus.

Migraciones: adaptar los datos a la lógica nueva

Una migración es lógica autocontenida que corre después de una actualización. El uso típico es pasar el storage de un pallet de un formato a otro, como en el caso del principio, pero también puede limpiar items viejos o llamar a funciones de otros pallets.

Matriz de decisión

Migración de un bloque o de varios

Recomendado

Single-block

Implementa OnRuntimeUpgrade y corre entera al principio del primer bloque con el runtime nuevo.

Ideal para
Cambios acotados cuyo costo no depende de datos que un usuario pueda inflar barato.
Ojo con
Si se pasa del peso o del PoV, el bloque no se puede producir y la cadena queda bloqueada.
Precaución

Multi-block

El trabajo se reparte en pasos a lo largo de varios bloques.

Ideal para
Recorrer storage grande o que crece con el uso.
Ojo con
Mientras dura, set_code se rechaza y el estado convive en dos formatos: hay que diseñar qué pasa con las llamadas.
El criterio es si el trabajo cabe con seguridad en un bloque, incluso si alguien infla el estado antes.

La advertencia más útil de la documentación es la de los tests: que los unitarios pasen no significa que la migración sea segura. Antes de desplegar hay que comprobar que no entra en pánico, que no toca tantas claves como para producir un PoV excesivo y que no tarda demasiado. La herramienta es try-runtime-cli con su subcomando on-runtime-upgrade, que ejecuta la migración contra el estado real de la cadena; la documentación dice que los desarrolladores deben correrlo siempre, idealmente en CI. Chopsticks es otra opción para el mismo fin.

Ni siquiera eso basta frente a un adversario. try-runtime-cli mide con el estado de hoy, y alguien puede inflar ese estado antes del bloque de la migración. Por eso la regla es no recorrer en una migración de un bloque storage que se pueda agrandar rápido o barato; si no hay alternativa, usar una de varios bloques.

El límite: cuándo sí hay que actualizar el nodo

"Sin fork" tiene una condición. El runtime llama al nodo mediante host functions, y el nodo debe implementar todas las que el runtime necesite. Como un nodo quiere poder re-ejecutar bloques viejos, debe mantener para siempre el soporte de las host functions antiguas, y agregar una nueva es un compromiso grande.

La consecuencia operativa: si el runtime nuevo usa una host function que el nodo no tiene, primero hay que distribuir un binario de nodo que la implemente y esperar a que lo adopten los validadores; después, actualizar el runtime. Mientras el cambio quede dentro del runtime, ningún operador tiene que hacer nada. Lo mismo vale para el consenso, que vive en el nodo: cambiar de algoritmo de consenso no es un runtime upgrade.

Qué cambia respecto de Ethereum

En Ethereum, cambiar las reglas del protocolo es un hard fork: cada equipo de cliente publica una versión y los operadores actualizan antes de una altura acordada. En Substrate la lógica de aplicación y buena parte del protocolo de la cadena viven en el runtime, así que el mismo cambio se decide en la cadena y se aplica en un bloque concreto.

El paralelo más cercano no está en el protocolo de Ethereum sino en sus contratos. Los contratos actualizables con proxies resuelven el mismo problema en otra capa: separan el código del almacenamiento y apuntan a una implementación nueva. Los riesgos coinciden: quién tiene la llave de la actualización y qué pasa si el diseño del almacenamiento cambia sin migrar.

La pregunta de quién autoriza es de gobernanza, no de tecnología. La gobernanza en la cadena analiza cómo una votación se convierte en autoridad para cambiar reglas, y qué demoras protegen a los usuarios que no están de acuerdo.

Decisiones antes de la primera actualización en producción

Lista de verificación

Antes de llamar a set_code fuera de una red de pruebas

0 de 6 revisados

El siguiente capítulo, offchain workers, muestra una ventaja lateral de que la lógica viaje en el Wasm: el código que corre fuera de la cadena se actualiza junto con el runtime.

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    Parity Technologies, "Runtime Upgrades," polkadot_sdk_docs, paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

    Actualización sin fork, set_code por un origen con permiso, migraciones single-block y multi-block, try-runtime-cli y riesgo de PoV manipulable.

  2. [2]

    Web3 Foundation, "States and Transitions," Polkadot Protocol Specification, w3f/polkadot-spec, GitHub repository, accedido 2026-11-13.

    El ejecutor debe cargar siempre el runtime más reciente del storage, según el bloque padre al importar; blobs Wasm comprimidos con zstd.

  3. [3]

    Polkadot Developers, "Runtime Upgrades," polkadot-developers/polkadot-docs, GitHub repository (fuente de docs.polkadot.com), accedido 2026-11-13.

    docsID: PD-RUNTIME-UPGRADESAbrir fuente (abre en otra pestaña)

    Tutorial de actualización con sudo y setCode; advertencia sobre no subir spec_version; en producción se usa gobernanza.

  4. [4]

    Parity Technologies, "frame/system/src/lib.rs," paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

    source codeID: FRAME-SYSTEMAbrir fuente (abre en otra pestaña)

    set_code y set_code_without_checks, can_set_code (spec_name igual, spec_version mayor), authorize_upgrade, :pending_code, evento CodeUpdated y digest RuntimeEnvironmentUpdated, LastRuntimeUpgrade.

  5. [5]

    Parity Technologies, "frame/executive/src/lib.rs," paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

    source codeID: FRAME-EXECUTIVEAbrir fuente (abre en otra pestaña)

    initialize_block ejecuta on_runtime_upgrade si runtime_upgraded() compara LastRuntimeUpgrade con la versión actual; diagrama de ejecución de bloque.

  6. [6]

    Parity Technologies, "WASM Meta Protocol," polkadot_sdk_docs, paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

    Host functions y runtime APIs; el nodo debe mantener las host functions viejas; agregar una nueva es un compromiso que en Polkadot exige un RFC.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.