Semántica contractual

Este documento pretende aclarar la semántica de cómo un contrato CosmWasm interactúa con su entorno. Existen dos tipos principales de acciones: las acciones mutantes, que reciben DepsMut y pueden modificar el estado de la blockchain, y las acciones de consulta, que se ejecutan en un único nodo con acceso de solo lectura a los datos.

Ejecucion

La siguiente sección cubrirá cómo funciona la llamada a ejecución, pero es importante tener en cuenta que los mismos principios se aplican a otras acciones de mutación, como instantiate, migrate, sudo, etc.

Contexto SDK

Antes de examinar CosmWasm, primero deberíamos explorar la semántica impuesta por el marco de blockchain con el que se integra: el SDK de Cosmos. Este marco se basa en el motor de consenso BFT de Tendermint. Para entenderlo mejor, examinemos primero cómo se procesan las transacciones antes de que lleguen a CosmWasm y después de que salgan.

En primer lugar, el motor Tendermint busca el consenso de 2/3+ sobre una lista de transacciones que se incluirán en el siguiente bloque. Esto se hace sin ejecutar las transacciones. Simplemente son sometidas a un pre-filtro mínimo por el módulo SDK de Cosmos para asegurar que son transacciones formateadas válidamente con suficientes tasas de gas y que están firmadas por una cuenta con fondos suficientes para pagar las tasas. Esto significa que muchas transacciones que dan lugar a un error pueden incluirse en un bloque.

Una vez que se consigna un bloque (normalmente cada 5 segundos más o menos), las transacciones se envían al SDK de Cosmos de forma secuencial para su ejecución. Cada transacción devuelve un resultado o devuelve un error, junto con los registros de eventos, que se registran en la sección TxResults del siguiente bloque. El AppHash (o Merkle proof o blockchain state) que se genera tras ejecutar el bloque también se incluye en el siguiente bloque.

La BaseApp del SDK de Cosmos gestiona cada transacción en un contexto aislado. En primer lugar, verifica todas las firmas y deduce las tasas de gas. Establece el "contador de gas" para limitar la ejecución a la cantidad de gas pagada por las tasas. Por último, crea un contexto aislado para ejecutar la transacción. Esto permite al código leer el estado actual de la cadena una vez finalizada la última transacción, pero sólo escribe en una caché que puede ser confirmada o revertida en caso de error.

Una transacción puede consistir en múltiples mensajes, y cada uno se ejecuta a su vez bajo el mismo contexto y el mismo límite de gas. Si todos los mensajes tienen éxito, el contexto se registrará en el estado subyacente de la cadena de bloques y los resultados de todos los mensajes se almacenarán en TxResult. Si un mensaje falla, se omiten todos los mensajes posteriores y se revierten todos los cambios de estado. Esto es crucial para la atomicidad.

Por ejemplo, si Alice y Bob firman una transacción con dos mensajes, como Alice paga a Bob 1.000 ATOM y Bob paga a Alice 50 ETH, y Bob no tiene los fondos en su cuenta, el pago de Alice también será revertido. Esto es similar a como funciona una transacción típica en una base de datos.

El x/wasm es un módulo personalizado del SDK de Cosmos que procesa mensajes específicos y los utiliza para cargar, instanciar y ejecutar contratos inteligentes. En particular, acepta un MsgExecuteContract debidamente firmado y lo enruta a Keeper.Execute, que carga el contrato inteligente apropiado y llama a ejecutar en él.

Tenga en cuenta que este método puede devolver éxito (con datos y eventos) o un error. En caso de error, se revertirá toda la transacción del bloque. Este es el contexto en el que nuestro contrato recibe la llamada de ejecución.

Ejecución básica

Al implementar un contrato, proporcionamos el siguiente punto de entrada:

pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> { }

Con DepsMut, esta función puede leer y escribir en el Almacenamiento de respaldo, así como utilizar la Api para validar direcciones, y Consultar el estado de otros contratos o módulos nativos. Una vez hecho esto, devuelve Ok(Response) o Err(ContractError). Examinemos lo que ocurre a continuación:

Si devuelve Err, este error se convierte en una representación de cadena (err.to_string()), y ésta se devuelve al módulo SDK. Todos los cambios de estado son revertidos, y x/wasm devuelve este mensaje de error, que generalmente (ver excepción submensaje más abajo) abortará la transacción y devolverá este mismo mensaje de error al llamador externo.

Si devuelve Ok, entonces el objeto Response es analizado y procesado. Veamos las partes aquí:

pub struct Response<T = Empty>
where
    T: Clone + fmt::Debug + PartialEq + JsonSchema,
{
    /// Optional list of "subcalls" to make. These will be executed in order
    /// (and this contract's subcall_response entry point invoked)
    /// *before* any of the "fire and forget" messages get executed.
    pub submessages: Vec<SubMsg<T>>,
    /// After any submessages are processed, these are all dispatched in the host blockchain.
    /// If they all succeed, then the transaction is committed. If any fail, then the transaction
    /// and any local contract state changes are reverted.
    pub messages: Vec<CosmosMsg<T>>,
    /// The attributes that will be emitted as part of a "wasm" event
    pub attributes: Vec<Attribute>,
    pub data: Option<Binary>,
}

En el SDK de Cosmos, una transacción devuelve una serie de eventos al usuario, junto con un "resultado de datos" opcional. Este resultado se incluye en el hash del siguiente bloque para que sea comprobable y pueda proporcionar información esencial sobre el estado. Sin embargo, las aplicaciones cliente suelen confiar más en los eventos. El resultado también se utiliza habitualmente para pasar información entre contratos o módulos en el SDK. Tenga en cuenta que el ResultHash incluye sólo el Código (distinto de cero indica un error) y el Resultado (datos) de la transacción. Aunque los eventos y registros son accesibles a través de consultas, no hay pruebas de cliente ligero disponibles para ellos.

Si el contrato establece datos, éstos se devolverán en el campo Resultado. Los atributos son una lista de pares {clave, valor}, que se añadirán a un evento por defecto.

El resultado final aparece así para el cliente:

{
  "type": "wasm",
  "attributes": [
    { "key": "contract_addr", "value": "cosmos1234567890qwerty" },
    { "key": "custom-key-1", "value": "custom-value-1" },
    { "key": "custom-key-2", "value": "custom-value-2" }
  ]
}

Envío de mensajes

Pasemos ahora al campo de los mensajes. Algunos contratos sólo necesitan hablar consigo mismos, como un contrato CW20 que simplemente ajusta sus saldos en las transferencias. Sin embargo, muchos contratos quieren mover tokens (nativos o CW20) o llamar a otros contratos para acciones más complejas. Aquí es donde entran en juego los mensajes. Devolvemos un CosmosMsg, que es una representación serializable de cualquier llamada externa que pueda hacer un contrato.

Por ejemplo, con la función stargate activada, tiene este aspecto:

pub enum CosmosMsg<T = Empty>
where
    T: Clone + fmt::Debug + PartialEq + JsonSchema,
{
    Bank(BankMsg),
    /// This can be defined by each blockchain as a custom extension
    Custom(T),
    Staking(StakingMsg),
    Distribution(DistributionMsg),
    Stargate {
        type_url: String,
        value: Binary,
    },
    Ibc(IbcMsg),
    Wasm(WasmMsg),
}

Si un contrato devuelve dos mensajes (M1 y M2), ambos serán parseados y ejecutados en x/wasm con los permisos del contrato (lo que significa que info.sender será el contrato, no el llamador original). Si vuelven con éxito, emitirán un nuevo evento con los atributos personalizados, y el campo de datos será ignorado. Cualquier mensaje que devuelvan también será procesado. Si devuelven un error, la llamada padre devolverá un error, retrocediendo así el estado de toda la transacción.

Tenga en cuenta que los mensajes se ejecutan primero en profundidad. Esto significa que si el contrato A devuelve M1 (WasmMsg::Execute) y M2 (BankMsg::Send), y si el contrato B (del WasmMsg::Execute) devuelve N1 y N2 (por ejemplo, StakingMsg y DistributionMsg), los mensajes se ejecutarán en el siguiente orden: M1, N1, N2, M2.

Esto puede ser difícil de entender al principio, y puede que te preguntes por qué no puedes simplemente llamar a otro contrato. Sin embargo, CosmWasm hace esto para evitar uno de los agujeros de seguridad más extendidos (y más difíciles de detectar) en los contratos de Ethereum: Reentrancy. CosmWasm hace esto siguiendo el modelo actor, que no anida llamadas a funciones, sino que devuelve mensajes que se ejecutarán más tarde. Esto significa que todo el estado que se arrastra entre una llamada y la siguiente ocurre en el almacenamiento y no en la memoria. Para más información sobre este diseño, consulta el Modelo Actor.

Submensajes

A partir de CosmWasm 0.14 (Abril 2021), añadieron otra forma de despachar llamadas desde el contrato, debido a la petición común de poder obtener el resultado de uno de los mensajes que despachaste. Por ejemplo, ahora es posible crear un nuevo contrato con WasmMsg::Instantiate, y luego almacenar la dirección del contrato recién creado en la llamada con submensajes. También aborda un caso de uso similar de capturar resultados de errores, de modo que si ejecuta un mensaje desde, por ejemplo, un contrato cron, puede almacenar el mensaje de error y marcar el mensaje como ejecutado, en lugar de abortar toda la transacción. También permite limitar el uso de gas del submensaje (esto no está pensado para ser utilizado en la mayoría de los casos, pero es necesario para, por ejemplo, el contrato cron para protegerlo de un bucle infinito en el submensaje, que podría quemar todo el gas y abortar la transacción).

Esto hace uso de CosmosMsg como se mencionó anteriormente, pero lo envuelve dentro de un sobre SubMsg:

pub struct SubMsg<T = Empty>
where
    T: Clone + fmt::Debug + PartialEq + JsonSchema,
{
    pub id: u64,
    pub msg: CosmosMsg<T>,
    pub gas_limit: Option<u64>,
    pub reply_on: ReplyOn,
}
pub enum ReplyOn {
    /// Always perform a callback after SubMsg is processed
    Always,
    /// Only callback if SubMsg returned an error, no callback on success case
    Error,
    /// Only callback if SubMsg was successful, no callback on error case
    Success,
}

¿Cuál es la semántica de la ejecución de un submensaje? En primer lugar, creamos un contexto de subtransacción alrededor del estado, permitiéndole leer el último estado escrito por el llamante, para escribir en otra caché. Si se establece gas_limit, se limita a la cantidad de gas que puede utilizar hasta que aborta con OutOfGasError. Este error es capturado y devuelto a la persona que llama como cualquier otro error devuelto de la ejecución del contrato (a menos que quemó todo el límite de gas de la transacción). Lo que es más interesante es lo que ocurre al finalizar.

Si se devuelve con éxito, el estado temporal se consigna (en la caché del llamante), y la Respuesta se procesa normalmente (se añade un evento al Gestor de Eventos actual, y se ejecutan los mensajes y submensajes). Una vez que la Respuesta es procesada completamente, puede ser interceptada por el contrato llamante (para ReplyOn::Always y ReplyOn::Success). En caso de error, la llamada secundaria revertirá cualquier cambio de estado parcial debido a este mensaje, pero no revertirá ningún cambio de estado en el contrato de llamada. El error puede ser interceptado por el contrato de llamada (para ReplyOn::Always y ReplyOn::Error). En este caso, el mensaje de error no aborta toda la transacción.

Tratamiento de la respuesta

Para poder utilizar submensajes, el contrato de llamada debe poseer un punto de entrada adicional:

#[entry_point]
pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result<Response, ContractError> { }
pub struct Reply {
    pub id: u64,
    /// ContractResult is just a nicely serializable version of `Result<SubcallResponse, String>`
    pub result: ContractResult<SubcallResponse>,
}
pub struct SubcallResponse {
    pub events: Vec<Event>,
    pub data: Option<Binary>,
}

Una vez finalizado el submensaje, el llamante tendrá la oportunidad de manejar el resultado. Recibirá el id original de la subllamada, que se puede utilizar para cambiar sobre cómo procesar el resultado, así como el Resultado de la ejecución, incluyendo tanto los casos de éxito como de error. Tenga en cuenta que incluye todos los eventos devueltos por el submensaje, lo que se aplica a los módulos nativos del SDK como Bank, así como los datos devueltos por el submensaje. Esto, junto con el identificador de llamada original, proporciona todo el contexto necesario para continuar el procesamiento. Si necesita más estado, debe guardar algo de contexto local en el almacén (bajo el id) antes de devolver el submensaje en la función de ejecución original y cargarlo en la respuesta. CosmWasm prohíbe explícitamente pasar información a través de la memoria del contrato, ya que ese es el vector clave para los ataques de reentrada, que representan una gran superficie de seguridad en Ethereum.

La propia llamada de respuesta puede devolver un Err, en cuyo caso se trata como si el llamante hubiera cometido un error, y la transacción se aborta. Sin embargo, si el proceso tiene éxito, reply puede devolver una Response normal, que será procesada como de costumbre, con eventos añadidos al EventManager y todos los mensajes y submensajes enviados como se ha descrito anteriormente.

La única diferencia crítica con reply es que no soltamos datos. Si reply devuelve data: Some(valor) en el objeto Response, CosmWasm sobrescribirá el campo de datos devuelto por el invocador. Es decir, si ejecutar devuelve data: Some(b "primera idea") y la respuesta (con toda la información extra a la que tiene acceso) devuelve data: Some(b "mejor idea"), entonces esto se devolverá a quien llamó a execute (ya sea el cliente u otra transacción), igual que si el execute original hubiera devuelto data: Some(b "mejor idea"). Si reply devuelve data: None, no modificará ningún estado de datos previamente establecido. Si hay varios submensajes, sólo se utiliza el último (todos sobrescriben cualquier valor de datos anterior). En consecuencia, puede utilizar data: Some(b"") para borrar los datos anteriores. Esto se representará como una cadena JSON en lugar de null y se manejará como cualquier otro valor Some.

Orden y retroceso

Los submensajes (y sus respuestas) se ejecutan antes que cualquier mensaje. También siguen las reglas de "primero en profundidad", como en el caso de los mensajes. He aquí un ejemplo sencillo: El contrato A devuelve los submensajes S1 y S2, y el mensaje M1. El submensaje S1 devuelve el mensaje N1. El orden será: S1, N1, reply(S1), S2, reply(S2), M1.

Tenga en cuenta que la ejecución del submensaje y la respuesta pueden producirse en el contexto de otro submensaje. Por ejemplo, contrato-A--submensaje --> contrato-B--submensaje --> contrato-C. Entonces, el contrato-B puede revertir el estado para el contrato-C y para sí mismo devolviendo Err en la respuesta del submensaje, pero no revertir el contrato-A o la transacción completa. Sólo termina devolviendo Err a la función de respuesta del contrato-A.

Tenga en cuenta que los errores no se manejan con ReplyOn::Success, lo que significa que, en tal caso, un error se tratará igual que un mensaje normal que devuelve un error. Este diagrama puede ayudar a explicarlo. Imagina que un contrato devuelve dos submensajes: (a) con ReplyOn::Success y (b) con ReplyOn::Error:

Semantica de consulta

Hasta ahora, nos hemos centrado en el objeto Respuesta, que nos permite ejecutar código en otros contratos a través del modelo de actor. Esto significa que cada contrato se ejecuta secuencialmente, uno tras otro, y las llamadas anidadas no son posibles. Esto es esencial para evitar la reentrada, que ocurre cuando una llamada a otro contrato cambia de estado mientras una transacción está en progreso.

Sin embargo, hay muchos casos en los que necesitamos acceder a información de otros contratos durante el procesamiento, como determinar el saldo bancario de un contrato antes de enviar fondos. Para habilitar esto, CosmWasm ha habilitado el Querier de solo lectura para permitir llamadas sincrónicas durante la ejecución. Al hacerlo de solo lectura (y aplicarlo a nivel de VM), CosmWasm puede evitar la posibilidad de reentrada, ya que la consulta no puede modificar ningún estado ni ejecutar nuestro contrato.

Cuando "hacemos una consulta", serializamos una estructura QueryRequest que representa todas las llamadas posibles. Luego, lo pasamos a través de FFI al tiempo de ejecución, donde se interpreta en el módulo SDK de x/wasm. Este proceso es extensible con consultas personalizadas específicas de blockchain, del mismo modo que CosmosMsg acepta resultados personalizados. Además, tenga en cuenta la capacidad de realizar consultas protobuf "Stargate" sin procesar.

pub enum QueryRequest<C: CustomQuery> {
    Bank(BankQuery),
    Custom(C),
    Staking(StakingQuery),
    Stargate {
        /// this is the fully qualified service path used for routing,
        /// eg. custom/cosmos_sdk.x.bank.v1.Query/QueryBalance
        path: String,
        /// this is the expected protobuf message type (not any), binary encoded
        data: Binary,
    },
    Ibc(IbcQuery),
    Wasm(WasmQuery),
}

Si bien esto es flexible y necesario para la representación en varios idiomas, puede ser un poco engorroso de generar y usar cuando solo desea encontrar su saldo bancario. Para ayudar con esto, a menudo usamos QuerierWrapper, que envuelve un Querier y expone muchos métodos convenientes que usan QueryRequest y Querier.raw_query bajo el capó.

Para obtener una explicación más detallada del diseño del Querier, consulte Consulta del estado del contrato.

Last updated