Substrate: del runtime al consenso propio

Artículo 6 de 20

30%

completado al llegar aquí

Sistemas distribuidosNota de investigación16 min de lectura

Extrinsics: transacciones firmadas, sin firmar e inherentes

Qué entra a un bloque Substrate desde fuera del runtime, cómo se codifica un extrinsic versión 5 y qué comprueban las transaction extensions antes de ejecutarlo.

Ruta Substrate: 0/20

Un equipo integra su billetera con una cadena construida con Substrate (Polkadot SDK). Durante semanas todo funciona. Un martes la cadena aplica una actualización del runtime y, desde ese bloque, cada transferencia que la billetera tenía firmada y en cola vuelve rechazada con un error de prueba inválida. Nadie tocó las claves ni el código de firma. Lo que cambió fue un número que el usuario nunca ve: la versión del runtime, que forma parte de lo que se firma.

Este capítulo explica por qué. Para entenderlo hay que saber qué es un extrinsic, cómo se convierte en bytes, qué parte de esos bytes se firma y qué comprobaciones corre el runtime antes de aceptarlo en el pool o en un bloque.

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

Usuario, autor del bloque o nodo

El usuario firma transacciones; el autor del bloque pone los inherentes; un nodo puede enviar transacciones sin firmar.

Evidencia

Bytes SCALE del extrinsic

Versión, firma opcional, datos de las extensiones y la llamada; el runtime los valida, el nodo solo los transporta.

Estado

Nonce, saldo y storage de los pallets

Cada extrinsic aplicado sube el nonce, cobra la comisión y ejecuta una llamada despachable.

Qué es un extrinsic y por qué no se llama transacción

El glosario del Polkadot SDK define el término sin rodeos: un extrinsic es un dato que se origina fuera del runtime, se incluye en un bloque y provoca alguna acción. Incluye las transacciones de los usuarios y también los inherentes que el constructor del bloque coloca en él.

La palabra existe porque "transacción" se queda corta. En Ethereum todo lo que entra a un bloque es una transacción firmada por una cuenta. En Substrate también entran datos que nadie firma, como la marca de tiempo del bloque. Si recuerdas el capítulo de FRAME, cada uno de esos datos termina llamando a una función despachable de algún pallet.

Hay tres familias, y conviene fijarlas desde el principio:

  • Firmadas. Una cuenta las autoriza con su clave y normalmente pagan comisión. Una transferencia de saldo es el caso típico.
  • Sin firmar. Llegan sin firma ni comisión. Como nada las encarece, el pallet que las acepta debe escribir su propia validación para no abrir la puerta al spam.
  • Inherentes. Las inserta quien produce el bloque, no circulan por la red como mensajes sueltos y llevan datos que el bloque necesita, como el tiempo.

La especificación del protocolo lo dice desde el lado del nodo: para el host cada extrinsic es solo un blob codificado en SCALE, y su contenido lo define y lo interpreta el runtime. El nodo transporta y ordena; solo el runtime sabe qué significa cada byte.

Los bytes de un extrinsic versión 5

La mayoría de los runtimes usa el tipo sp_runtime::generic::UncheckedExtrinsic. Su documentación describe la codificación de la versión de formato 5 como la concatenación de cuatro piezas:

Texto
extrinsic_bytes = concat(
    compact_encoded_length,      // longitud del resto, en SCALE compacto
    version_and_extrinsic_type,  // 1 byte: tipo (2 bits) + versión (6 bits)
    maybe_extension_data,        // dirección, firma y extensiones, según el tipo
    call_data                    // índice de pallet, índice de llamada, argumentos
)

El primer byte útil dice qué clase de extrinsic viene. Los dos bits más significativos marcan el tipo y los seis restantes la versión, hoy 5:

  • Bare (0b00, byte 0b0000_0101): no lleva datos de extensión; después del byte viene directamente la llamada. Así viajan los inherentes y las transacciones sin firmar al estilo clásico.
  • Signed (0b10, byte 0b1000_0101): lleva la dirección del emisor, la firma y los bytes de las extensiones.
  • General (0b01, byte 0b0100_0101): no trae una firma fija; lleva extensiones, y alguna de ellas es la que autoriza el origen.

La llamada se codifica con dos bytes de índice y los argumentos. El primero identifica el pallet en el runtime y el segundo la función dentro del pallet. Por eso un cliente no puede construir llamadas sin leer la metadata de la cadena: los índices los fija cada runtime, y la metadata publica cuáles son, junto con las extensiones que exige y en qué orden.

Figura 1

Anatomía de un extrinsic firmado versión 5

Fuentes:

[4][5]
Anatomía de un extrinsic firmado versión 5
Cómo leer esta figura

Fila superior de cajas, de izquierda a derecha: longitud compacta; byte de versión y tipo (0b1000_0101 para firmado); dirección del emisor; firma; extra de las extensiones (era, nonce, propina); y call_data con índice de pallet, índice de llamada y argumentos. Debajo, a la izquierda, un recuadro recuerda los dos bits altos del primer byte: bare 00, signed 10, general 01; otro, en rojo, advierte que subir spec_version invalida firmas hechas antes. A la derecha, la caja del signed payload es blake2_256 de la concatenación de call_data, extra e implicit: flechas desde call_data y extra bajan hasta ella y una flecha sube desde ella hasta la firma, rotulada se firma con la clave. Bajo el payload, un recuadro discontinuo, implicit, lista spec_version, transaction_version, hash de génesis y hash del bloque de nacimiento: datos que no viajan en el extrinsic y que el runtime reconstruye.

Visual original de TrautsLab. Arriba, los campos de un extrinsic firmado en el orden en que se serializan; abajo, el payload que se firma. Las extensiones aportan dos cosas: datos explícitos que viajan en el extrinsic (extra) y datos implícitos que solo entran en la firma, como la versión del runtime o el hash de génesis.

Un detalle de la propia documentación merece cuidado: un párrafo antiguo todavía describe el byte de una transacción sin firmar con la versión 4. El resto del texto, y el formato vigente, usan la versión 5. Si un decodificador viejo falla con extrinsics nuevos, ese cambio de versión es lo primero que hay que revisar.

El payload que se firma

La firma no cubre los bytes del extrinsic tal como viajan. Cubre un signed payload construido así:

Texto
signed_payload = blake2_256(
    concat(call_data, transaction_extensions_extra, transaction_extensions_implicit)
)

La pieza nueva es implicit. Cada extensión puede aportar datos que no viajan en el extrinsic pero que el runtime reconstruye desde su propio estado al verificar la firma. CheckSpecVersion aporta la spec_version del runtime activo; CheckGenesis, el hash del bloque génesis; CheckMortality, el hash del bloque desde el que cuenta la vida de la transacción.

Ahí está la respuesta al caso del principio. La billetera firmó con spec_version 100. Tras la actualización, el runtime reconstruye el payload con 101, el hash cambia y la firma deja de corresponder. No hay error de red ni de claves: es una protección diseñada. Una transacción firmada para un runtime no puede ejecutarse por accidente con otro.

La misma idea explica por qué una transacción de una red de pruebas no se puede reenviar en la red principal: el hash de génesis entra en la firma aunque no viaje en los bytes.

Transaction extensions: los controles previos

Las transaction extensions son, en palabras de la documentación, la forma en que cada cadena extiende el formato básico del extrinsic con datos propios que el runtime comprueba. La documentación antigua las llama signed extensions; el nombre cambió cuando dejaron de depender de que hubiera una firma.

FRAME trae un juego estándar. Cada una responde una pregunta concreta:

Flujo visual

Qué comprueba cada extensión antes de despachar la llamada

El orden real lo fija la tupla de extensiones de cada runtime; la metadata lo publica para los clientes.
  1. CheckNonZeroSenderel emisor no es la cuenta de ceros
  2. CheckSpecVersion y CheckTxVersionfirmada para este runtime y esta codificación
  3. CheckGenesisfirmada para esta cadena
  4. CheckMortalitytodavía dentro de su ventana de vida
  5. CheckNonceorden por cuenta y protección contra repetición
  6. CheckWeightcabe en el bloque
  7. ChargeTransactionPaymentcobra la comisión y la propina

CheckNonce merece un párrafo propio. Si el nonce de la transacción es menor que el de la cuenta, la transacción es vieja y se rechaza como stale. Si es mayor, no es inválida: es future y espera en el pool a que lleguen las que faltan. Para ordenar, la extensión publica etiquetas: cada transacción provee el par (cuenta, nonce) y requiere el par anterior. El pool usa esas etiquetas para saber qué está listo.

La mortalidad cierra otro hueco. Una transacción mortal vale desde un bloque de referencia durante un número de bloques; si no entra a tiempo, sale del pool. Una inmortal no caduca, y eso es peligroso: si la cuenta se vacía, se elimina y se vuelve a crear con nonce 0, alguien podría repetir transacciones viejas que vuelven a ser válidas. Por la misma razón, el hash de una transacción no la identifica de forma única: la identidad correcta es el bloque más el índice dentro del bloque.

Del pool al bloque: validar no es ejecutar

Cuando un extrinsic llega a un nodo, el nodo llama a la runtime API validate_transaction. En FRAME esa función decodifica, comprueba la firma, corre la fase de validación de las extensiones y devuelve prioridad y etiquetas. Lo que escriba en el storage durante esa comprobación se descarta. Validar es preguntar "¿podría entrar?"; ejecutar es aplicarlo de verdad dentro de un bloque.

La misma función rechaza cualquier extrinsic de clase Mandatory que llegue desde fuera. Esa clase queda reservada a los inherentes, que no deben poder colarse por el pool.

Un inherente sigue otro camino. El nodo reúne los datos inherentes (por ejemplo, su reloj) y se los pasa al runtime con BlockBuilder_inherent_extrinsics; el runtime devuelve los extrinsics que hay que poner y el autor los coloca al principio del bloque. Nadie firma la marca de tiempo. La protección es otra: los demás nodos, al importar el bloque, comprueban que el valor esté dentro de un margen aceptable. Si no lo está, el bloque es inválido.

Qué protege a cada una

Tres maneras de entrar a un bloque

La pregunta útil no es si hay firma, sino qué impide el abuso.

Firmada

Una cuenta autoriza con su clave y paga.

  • Protección: firma, nonce y comisión.
  • Circula por la red y espera en el pool.

Sin firmar

Llega sin firma ni comisión.

  • Protección: lógica propia del pallet, que debe limitar frecuencia y origen.
  • Sin esa lógica, es una invitación al spam.

Inherente

La pone el autor del bloque.

  • Protección: los demás nodos verifican el valor al importar el bloque.
  • No pasa por el pool ni se difunde suelta.

Las transacciones sin firmar son la familia delicada. El ejemplo clásico es un latido de un validador que demuestra que sigue vivo: solo los validadores pueden enviarlo, así que el pallet comprueba esa condición y no cobra. Hoy el formato general y el atributo #[pallet::authorize] permiten expresar esa autorización como una extensión más, en vez de una excepción. El capítulo de offchain workers vuelve sobre este patrón, porque es la vía por la que un worker reporta resultados.

Qué cambia respecto de Ethereum

Si vienes de la transacción de Ethereum por dentro, la tentación es buscar el campo equivalente para cada cosa. Algunos existen; otros cambian de lugar.

  • Chain ID frente a génesis. Ethereum mete el chainId en la transacción firmada. Substrate mete el hash de génesis en los datos implícitos: no viaja, pero se firma.
  • Una sola forma frente a extensiones. El tipo de transacción de Ethereum fija sus campos por EIP. En Substrate cada runtime elige su tupla de extensiones, y la metadata la publica.
  • La versión del software entra en la firma. Ethereum no firma la versión del cliente. Substrate firma spec_version, y por eso una actualización del runtime invalida firmas pendientes.
  • Datos sin firma. Ethereum no tiene inherentes: la marca de tiempo va en la cabecera. Substrate la pasa por un extrinsic que el runtime valida como cualquier otro.

La firma también autoriza cosas distintas. En cuentas, claves y direcciones la dirección se deriva de la clave y la firma recupera al emisor. En Substrate la dirección viaja explícita en el extrinsic firmado, y el tipo de firma lo elige el runtime; en Polkadot es MultiSignature, que admite varios esquemas.

Decisiones para quien diseña la cadena y quien construye el cliente

Lista de verificación

Antes de aceptar extrinsics en producción

0 de 5 revisados

Queda una pieza abierta: CheckWeight y ChargeTransactionPayment mencionan un peso y una comisión que todavía no explicamos. Antes de llegar ahí hace falta saber dónde escribe un extrinsic. El siguiente capítulo, el estado: clave-valor, trie radix-16 y raíz de estado, muestra cómo se guarda ese storage y cómo se prueba un valor sin bajar todo. Después, pesos y comisiones cierra el círculo. El capítulo anterior, tu primer nodo local, sirve para ver todo esto en un nodo que corre en tu máquina.

Referencias

Bibliografía académica

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

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

    Definición de extrinsic: dato originado fuera del runtime, incluido en un bloque, que provoca una acción.

  2. [2]

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

    Transacciones firmadas, sin firmar e inherentes; pool con colas ready y future; mortalidad; identificar un extrinsic por bloque e índice.

  3. [3]

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

    Extrinsics como blobs SCALE opacos para el host, pool de transacciones e inherentes generados con BlockBuilder_inherent_extrinsics.

  4. [4]

    Parity Technologies, "Constructing and Signing Extrinsics," polkadot_sdk_docs, paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

    docsID: SDK-EXTRINSIC-ENCODINGAbrir fuente (abre en otra pestaña)

    Codificación de UncheckedExtrinsic versión 5: longitud compacta, byte de versión y tipo, datos de extensiones, call_data y signed payload con blake2_256.

  5. [5]

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

    Lista de extensiones de FRAME: CheckGenesis, CheckMortality, CheckNonZeroSender, CheckNonce, CheckSpecVersion, CheckTxVersion, CheckWeight, ChargeTransactionPayment y otras.

  6. [6]

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

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

    CheckSpecVersion declara type Implicit = u32 y devuelve la spec_version del runtime activo.

  7. [7]

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

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

    Validación del nonce: Stale si es menor, Future si es mayor, y etiquetas provides y requires por (cuenta, nonce).

  8. [8]

    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)

    validate_transaction comprueba sin efectos y rechaza la clase Mandatory; el diagrama de ejecución pone los inherentes antes de las transacciones.

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.