Substrate: del runtime al consenso propio

Artículo 8 de 20

40%

completado al llegar aquí

Sistemas distribuidosNota de investigación13 min de lectura

Pesos y comisiones: medir antes de ejecutar

Por qué Substrate no mide gas instrucción por instrucción, qué son ref_time y proof_size, cómo se escribe un benchmark del peor caso y cómo se calcula la comisión.

Ruta Substrate: 0/20

Un equipo publica un pallet de listas de distribución. La llamada notify_all recorre la lista de suscriptores de un canal y escribe un registro por cada uno. En pruebas, con diez suscriptores, el benchmark devolvió un peso pequeño y la comisión salió barata. Un mes después alguien crea un canal con cincuenta mil suscriptores y llama a notify_all. El autor del bloque incluye la transacción confiando en el peso declarado, la ejecución tarda mucho más de lo previsto y el bloque llega tarde a su turno.

El error no estuvo en la red ni en el consenso. Estuvo en medir el caso promedio en vez del peor. Este capítulo explica por qué Substrate (Polkadot SDK) necesita esa medición previa, qué mide exactamente y cómo de ahí sale lo que paga el usuario.

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

Quien escribe el pallet y el autor del bloque

El primero declara el peso con benchmarks; el segundo llena el bloque confiando en esa cifra.

Evidencia

Weight (ref_time, proof_size)

El resultado del benchmark, declarado en la llamada antes de ejecutarla.

Estado

Saldo del emisor y multiplicador de comisión

La comisión se retira antes de ejecutar; el multiplicador cambia al final de cada bloque.

Por qué no hay gas instrucción por instrucción

En la EVM cada instrucción tiene un costo fijo, la ejecución acumula gas y se detiene al llegar al límite. Eso se llama medición (metering). La documentación del SDK explica por qué el runtime de Substrate no funciona así: Wasm es una máquina de pila con un juego de instrucciones más complejo y tiempos de ejecución menos predecibles, y por eso el runtime en Wasm no se supone medido.

La segunda razón es de confianza. La EVM ejecuta contratos ajenos, que pueden ser maliciosos; sin medición, un contrato podría consumir recursos sin límite. El runtime, en cambio, es código confiable escrito por el mismo equipo que opera la cadena. Si recuerdas el runtime en Wasm, el runtime es la función de transición de la propia cadena, no un programa que sube cualquiera.

Sin medición en vivo sigue haciendo falta una cota. El autor de un bloque tiene un tiempo limitado para armarlo y necesita saber, antes de ejecutar una transacción, si cabe. La documentación pone el ejemplo de un autor al que le quedan 20 ms: en un entorno medido no sabría qué transacción cabe; en uno con benchmarks mira la cota superior de cada una e incluye las que entran en el peor caso.

Un peso con dos dimensiones

Esa cota es el peso (sp_weights::Weight). No es un número sino un par:

  • ref_time: el tiempo de ejecución en picosegundos sobre un hardware de referencia.
  • proof_size: cuánta prueba de storage hace falta para volver a ejecutar ese código. Importa sobre todo cuando una parachain tiene que enviar su bloque a la relay chain para que lo validen.

La segunda dimensión conecta con el capítulo del estado. Quien re-ejecuta un bloque sin tener el estado completo necesita los nodos del trie de cada clave que el bloque leyó o escribió. Cada lectura de storage cuesta tiempo y además engorda esa prueba. Por eso un bloque puede llenarse por tiempo o por tamaño de prueba, lo que llegue primero.

Las lecturas y escrituras se cuentan por clave única: varias lecturas del mismo valor cuentan como una, porque después de la primera el valor queda en caché.

El benchmark mide el peor caso

La regla más importante sobre benchmarks está escrita sin matices en la documentación: el código de benchmark debe capturar la ejecución del peor caso de cualquier bloque de código. Es un análisis estático: se conoce la cota antes de ejecutar, en la fase que FRAME llama pre-dispatch.

Si una llamada recorre una lista, el benchmark debe usar el tamaño máximo de la lista, y el pallet debe imponer ese máximo. En el caso del principio, el arreglo tiene dos partes: acotar los suscriptores por canal y declarar un peso que dependa del tamaño, por ejemplo con un argumento que diga cuántos hay.

El flujo de trabajo tiene tres piezas. Cada pallet trae su propio código de benchmark, protegido detrás de la feature runtime-benchmarks. Se ejecuta con el subcomando benchmark del nodo o con frame-omni-bencher, que solo necesita el runtime. El resultado genera una implementación del trait WeightInfo que el pallet recibe por su Config y usa en el atributo #[pallet::weight] de cada llamada.

Cuando el peor caso es mucho más caro que el caso habitual, la llamada puede devolver su peso real al terminar y el sistema reembolsa la diferencia:

rust
// Fragmento ilustrativo, simplificado de
// docs/sdk/src/reference_docs/frame_benchmarking_weight.rs (polkadot-sdk)
#[pallet::weight(T::WeightInfo::simple_transfer())] // peor caso, pre-dispatch
pub fn simple_transfer_3(origin: OriginFor<T>, dest: T::AccountId, amount: u32)
    -> DispatchResultWithPostInfo
{
    if destination_exists {
        Ok(().into()) // camino barato: se cobra el peso declarado
    } else {
        let actual_weight = /* otro benchmark, no el del peor caso */;
        Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes })
    }
}

El reembolso solo puede bajar el peso, nunca subirlo: si la llamada informa un peso real mayor que el declarado, FRAME registra un error en el log e ignora los componentes que exceden. El peso declarado sigue siendo la promesa que el autor del bloque usó para decidir que la transacción cabía.

Cuánto cabe en un bloque

Cada bloque tiene un peso máximo, y no todo está disponible para los usuarios. FRAME separa las llamadas en tres clases de despacho:

  • Normal: las transacciones habituales. Solo pueden usar una fracción del bloque, configurada con AvailableBlockRatio.
  • Operational: las que mantienen la red funcionando, como una actualización del runtime. Pueden usar el bloque entero, tienen prioridad máxima y no pagan comisión por longitud.
  • Mandatory: los inherentes. Entran aunque el bloque supere su límite, porque es preferible un bloque pesado a no tener bloque. A cambio deben ser livianas y aparecer una sola vez por bloque.

La extensión CheckWeight, que vimos en extrinsics, es la que comprueba que una transacción cabe antes de despacharla. Las extensiones WeightReclaim y, en parachains, StorageWeightReclaim devuelven al bloque el peso no usado tras ejecutar.

De peso a comisión

El peso dice cuánto cabe; la comisión dice cuánto se paga. El pallet transaction-payment define la fórmula:

Texto
inclusion_fee = base_fee + length_fee + [targeted_fee_adjustment * weight_fee]
final_fee     = inclusion_fee + tip
  • base_fee: el mínimo por existir, derivado de un peso base que cubre, por ejemplo, la verificación de la firma.
  • length_fee: un precio por byte del extrinsic codificado.
  • weight_fee: el peso convertido a moneda con WeightToFee.
  • targeted_fee_adjustment: un multiplicador que sube o baja con la congestión.
  • tip: una propina opcional que aumenta la prioridad en el pool.

La comisión se retira antes de ejecutar y se cobra aunque la llamada falle. Si el peso real resulta menor que el declarado, la comisión se corrige y se devuelve la diferencia.

Figura 1

Vida de una transacción en pesos y comisiones

Fuentes:

[1][5]
Vida de una transacción en pesos y comisiones
Cómo leer esta figura

Cinco pasos de izquierda a derecha. Uno: el benchmark del peor caso fija el peso declarado, un par ref_time y proof_size. Dos: CheckWeight comprueba que ese peso cabe en la parte del bloque disponible para su clase. Tres: ChargeTransactionPayment retira base_fee más length_fee más multiplicador por weight_fee más propina. Cuatro: la llamada se ejecuta y puede devolver un peso real menor. Cinco: se reembolsa la diferencia de comisión y se devuelve peso al bloque. Debajo, una flecha de retorno indica que al final del bloque el multiplicador se actualiza según lo lleno que quedó respecto del objetivo, en la dimensión limitante, y afecta a las transacciones del bloque siguiente.

Visual original de TrautsLab. El peso se declara antes de ejecutar y decide si la transacción cabe; la comisión se cobra con ese peso y se corrige después con el peso real. Al cerrar el bloque, lo lleno que quedó ajusta el multiplicador que pagará el bloque siguiente.

El multiplicador que sigue a la congestión

La comisión por peso se multiplica por un factor que cambia de bloque en bloque. La implementación por defecto, TargetedFeeAdjustment, compara el peso del bloque anterior (s) con un peso objetivo (s'), normaliza la diferencia por el peso máximo (m) y actualiza:

diff=s−s′m,next=prev⋅(1+v⋅diff+(v⋅diff)22)\mathrm{diff} = \frac{s - s'}{m}, \qquad \mathrm{next} = \mathrm{prev} \cdot \left(1 + v \cdot \mathrm{diff} + \frac{(v \cdot \mathrm{diff})^2}{2}\right)

Si el bloque vino más lleno que el objetivo, diff es positivo y la comisión sube un poco; si vino más vacío, baja. La variable v controla la velocidad. Como el peso tiene dos dimensiones, el cálculo usa la dimensión limitante: la que esté más cerca de su máximo en proporción. Un bloque al 30 % en tiempo y al 25 % en prueba cuenta como lleno al 30 %.

El código trae un ejemplo numérico. Con 28 800 bloques por día, v igual a 0,00001 y un objetivo de 0,1875 del bloque, en congestión extrema total la comisión puede cambiar en torno a un 23 % por día. Es un ajuste lento a propósito: suaviza picos y hace que un ataque de saturación sostenido cueste cada vez más.

Las transacciones con necesidades especiales no se resuelven con la fórmula. Depósitos que se devuelven al liberar storage, fianzas que se pierden por mal comportamiento o límites fijos sobre una operación son herramientas aparte que cada pallet decide usar.

Qué cambia respecto de Ethereum

Si vienes de la transacción de Ethereum y el gas, el contraste es casi simétrico.

Dos modelos de costo

Medir durante o medir antes

Ninguno es gratis: uno paga en contabilidad por instrucción, el otro en disciplina de benchmarks.

Ethereum: gas

Cada instrucción suma gas mientras corre; al llegar al límite, la ejecución se detiene.

  • Pensado para código ajeno y no confiable.
  • El usuario fija un límite de gas y paga lo consumido.

Substrate: weight

Cada llamada declara su peor caso antes de correr.

  • Pensado para código confiable de la propia cadena.
  • Un benchmark mal escrito es un bug de seguridad, no un detalle de precio.

El ajuste por congestión también tiene un pariente. EIP-1559 mueve una base fee por bloque según lo lleno que vino el anterior. El multiplicador de Substrate persigue el mismo objetivo, pero es una pieza del runtime que cada cadena configura o reemplaza, no una regla del protocolo.

Un matiz para no confundirse: los contratos que corren sobre una cadena Substrate sí necesitan medición, porque son código ajeno. La documentación menciona que PolkaVM, una máquina virtual capaz de medir, podría volver innecesarios los benchmarks en el futuro. Los contratos quedan fuera del alcance de esta ruta.

Decisiones para quien escribe pallets

Lista de verificación

Antes de dar por bueno el peso de una llamada

0 de 5 revisados

El siguiente capítulo, actualizar sin fork, muestra una llamada que usa este sistema de una manera llamativa: la que cambia el runtime consume a propósito todo el peso que queda en el bloque.

Referencias

Bibliografía académica

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

    Parity Technologies, "FRAME Benchmarking and Weights," polkadot_sdk_docs, paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

    Wasm sin metering, runtime como código confiable, benchmarking estático del peor caso, Weight con ref_time y proof_size, reembolso manual y frame-omni-bencher.

  2. [2]

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

    Fórmula de inclusion_fee y final_fee, retiro previo de la comisión, conteo de lecturas y escrituras, clases Normal, Operational y Mandatory, corrección post-dispatch.

  3. [3]

    Parity Technologies, "frame/support/src/dispatch.rs (PostDispatchInfo::calc_actual_weight)," paritytech/polkadot-sdk, GitHub repository, accedido 2026-11-13.

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

    El peso post-dispatch mayor que el pre-dispatch se ignora y se registra como error.

  4. [4]

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

    CheckWeight comprueba que la transacción cabe en el bloque; ChargeTransactionPayment cobra según el peso; WeightReclaim y StorageWeightReclaim reclaman peso no usado.

  5. [5]

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

    Fórmula inclusion_fee y final_fee; TargetedFeeAdjustment con s, s', m, v; dimensión limitante; ejemplo de unos 23 % por día con congestión extrema.

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.