Contratos

Contratos en Kore Ledger.

1 - Contratos en Kore

Introducción a la programación de contratos en Kore Ledger.

Contratos y esquemas

En Kore, cada sujeto está asociado a un esquema que determina, fundamentalmente, sus propiedades. El valor de estas propiedades puede cambiar a lo largo del tiempo mediante la emisión de eventos, siendo necesario, en consecuencia, establecer el mecanismo a través del cual estos eventos realizan dicha acción. En la práctica, esto se gestiona a través de una serie de reglas que constituyen lo que llamamos un contrato.

En consecuencia, podemos decir que un esquema tiene siempre asociado un contrato que regula su evolución. La especificación de ambos se realiza en la gobernanza.

Entradas y salidas

Los contratos, aunque especificados en la gobernanza, sólo son ejecutados por aquellos nodos que tienen capacidades de evaluación y han sido definidos como tales en las reglas de gobernanza. Es importante señalar que Kore permite que un nodo actúe como evaluador de un sujeto incluso si no posee la cadena de eventos del sujeto, es decir, incluso si no es testigo. Esto ayuda a reducir la carga en estos nodos y contribuye al rendimiento general de la red.

Para lograr la correcta ejecución de un contrato, recibe tres entradas: el estado actual del sujeto, el evento a procesar y una flag que indica si la solicitud del evento ha sido emitida o no por el propietario del sujeto. Una vez recibidos estos insumos, el contrato debe utilizarlos para generar un nuevo estado válido. Tenga en cuenta que la lógica de esto último recae enteramente en el programador del contrato. El programador del contrato también determina qué eventos son válidos, es decir, decide la familia de eventos que se utilizará. Así, el contrato sólo aceptará eventos de esta familia, rechazando todos los demás, y que el programador pueda adaptar, en estructura y datos, a las necesidades de su caso de uso. Como ejemplo, supongamos un sujeto que representa el perfil de un usuario con su información de contacto así como su identidad; un evento de la familia podría ser aquel que solo actualice el número de teléfono del usuario. Por otro lado, la flag se puede utilizar para restringir ciertas operaciones únicamente al propietario del sujeto, ya que la ejecución del contrato se realiza tanto por los eventos que genera por sí mismo como por invocaciones externas.

Cuando un contrato termina de ejecutarse, genera tres resultados:

  • Flag de éxito: Mediante un booleano indica si la ejecución del contrato ha sido exitosa, es decir, si el evento debe provocar un cambio de estado del sujeto. Este indicador se establecerá en falso siempre que se produzca un error al obtener los datos de entrada del contrato o si la lógica del contrato así lo dicta. En otras palabras, se puede y se debe indicar explícitamente si la ejecución puede considerarse exitosa o no. Esto es importante porque estas decisiones dependen completamente del caso de uso, del cual Kore se abstrae en su totalidad. Así, por ejemplo, el programador podría determinar que si, tras la ejecución de un evento, el valor de una de las propiedades del sujeto ha superado un umbral, el evento no puede considerarse válido.

  • Estado final: Si el evento ha sido procesado exitosamente y la ejecución del contrato ha sido marcada como exitosa, entonces devuelve el nuevo estado generado, que en la práctica podría ser el mismo que el anterior. Este estado se validará con el esquema definido en la gobernanza para garantizar la integridad de la información. Si la validación no es exitosa, se cancela el indicador de éxito.

  • Flag de aprobación: el contrato debe decidir si un evento debe aprobarse o no. Nuevamente, esto dependerá enteramente del caso de uso, siendo responsabilidad del programador establecer cuándo es necesario. Así, la aprobación se fija como una fase facultativa pero también condicional.

Ciclo de vida

Desarrollo

Los contratos se definen en proyectos locales de Rust, el único lenguaje permitido para escribirlos. Estos proyectos, que debemos definir como bibliotecas, deben importar el SDK de los contratos disponibles en los repositorios oficiales y, además, deben seguir las indicaciones especificadas en “cómo escribir un contrato”.

Distribución

Una vez definido el contrato, se debe incluir en una gobernanza y asociar a un esquema para que pueda ser utilizado por los nodos de una red. Para ello es necesario realizar una operación de actualización de gobernanza en la que se incluye el contrato en el apartado correspondiente y se codifica en base64. Si se ha definido una batería de prueba, no es necesario incluirla en el proceso de codificación.

Compilación

Si la solicitud de actualización tiene éxito, el estado de gobernanza cambiará y los nodos evaluadores compilarán el contrato como un módulo de Web Assembly, lo serializarán y lo almacenarán en su base de datos. Se trata de un proceso automatizado y autogestionado, por lo que no requiere la intervención del usuario en ninguna etapa del proceso.

Después de este paso, el contrato puede ser utilizado.

Ejecución

La ejecución del contrato se realizará en un Web Assembly Runtime, aislando su ejecución del resto del sistema. Esto evita el mal uso de sus recursos, añadiendo una capa de seguridad.

Rust y WASM

Web Assembly se utiliza para la ejecución de contratos debido a sus características:

  • Alto rendimiento y eficiencia.
  • Ofrece un entorno de ejecución aislado y seguro.
  • Tiene una comunidad activa.
  • Permite compilar desde varios lenguajes, muchos de ellos con una base de usuarios considerable.
  • Los módulos resultantes de la compilación, una vez optimizados, son ligeros.

Se eligió Rust como lenguaje para escribir contratos de Kore debido a su capacidad de compilar en Web Assembly, así como a sus capacidades y especificaciones, la misma razón que motivó su elección para el desarrollo de Kore. Específicamente, Rust es un lenguaje centrado en escribir código seguro y de alto rendimiento, los cuales contribuyen a la calidad del módulo Web Assembly resultante. Además, el lenguaje cuenta de forma nativa con recursos para crear pruebas, lo que favorece la prueba de contratos.

2 - Programación de contratos

Cómo programar los contratos.

SDK

Para el correcto desarrollo de los contratos es necesario utilizar su SDK, proyecto que se puede encontrar en el repositorio oficial de Kore. El principal objetivo de este proyecto es abstraer al programador de la interacción con el contexto de la máquina evaluadora subyacente, haciendo mucho más fácil la obtención de los datos de entrada, así como el proceso de escritura del resultado del contrato.

El proyecto SDK se puede dividir en tres secciones. Por un lado, un conjunto de funciones cuya vinculación se produce en tiempo de ejecución y que tienen como objetivo poder interactuar con la máquina evaluadora, en particular, para leer y escribir datos en un buffer interno. Adicionalmente también distinguimos un módulo que, utilizando las funciones anteriores, se encarga de la serialización y deserialización de los datos, así como de proporcionar la función principal de cualquier contrato. Finalmente, destacamos una serie de funciones y estructuras de utilidad que se pueden utilizar activamente en el código.

Muchos de los elementos anteriores son privados, por lo que el usuario nunca tendrá la oportunidad de utilizarlos. Por ello, en esta documentación nos centraremos en aquellas que están expuestas al usuario y que éste podrá utilizar activamente en el desarrollo de sus contratos.

Estructuras auxiliares

#[derive(Serialize, Deserialize, Debug)]
pub struct Context<State, Event> {
    pub initial_state: State,
    pub event: Event,
    pub is_owner: bool,
}

Esta estructura contiene los tres datos de entrada de cualquier contrato: el estado inicial o actual del sujeto, el evento entrante y una flag que indica si la persona que solicita el evento es o no el propietario del sujeto. Tenga en cuenta el uso de genéricos para el estado y el evento.

#[derive(Serialize, Deserialize, Debug)]
pub struct ContractResult<State> {
    pub final_state: State,
    pub approval_required: bool,
    pub success: bool,
}

Contiene el resultado de la ejecución del contrato, siendo este una conjunción del estado resultante y dos flags que indican, por un lado, si la ejecución ha sido exitosa según los criterios establecidos por el programador (o si se ha producido en la carga de datos); y por otro lado, si el evento requiere aprobación o no.

pub fn execute_contract<F, State, Event>(
    state_ptr: i32,
    event_ptr: i32,
    is_owner: i32,
    callback: F,
) -> u32
where
    State: for<'a> Deserialize<'a> + Serialize + Clone,
    Event: for<'a> Deserialize<'a> + Serialize,
    F: Fn(&Context<State, Event>, &mut ContractResult<State>);

Esta función es la función principal del SDK y, además, la más importante. En concreto se encarga de obtener los datos de entrada, datos que obtiene del contexto que comparte con la máquina evaluadora. La función, que inicialmente recibirá un puntero a cada uno de estos datos, se encargará de extraerlos del contexto y deserializarlos al estado y estructuras de eventos que espera recibir el contrato, los cuales pueden especificarse mediante genéricos. Estos datos, una vez obtenidos, se encapsulan en la estructura de contexto presente arriba y se pasan como argumentos a una función de devolución de llamada que gestiona la lógica del contrato, es decir, sabe qué hacer con los datos recibidos. Finalmente, independientemente de si la ejecución ha sido exitosa o no, la función se encargará de escribir el resultado en el contexto, para que pueda ser utilizado por la máquina evaluadora.

pub fn apply_patch<State: for<'a> Deserialize<'a> + Serialize>(
    patch_arg: Value,
    state: &State,
) -> Result<State, i32>;

Esta es la última característica pública del SDK y permite actualizar un estado aplicando un JSON-PATCH, útil en los casos en los que se considera que esta técnica actualiza el estado.

Tu primer contrato

Creando el proyecto

Localice la ruta y/o los directorios deseados y cree un nuevo paquete de carga utilizando cargo new NOMBRE --lib. El proyecto debería ser una biblioteca. Asegúrese de tener un archivo lib.rs y no un archivo main.rs.

Luego, incluye en el Cargo.toml como dependencia el SDK de los contratos y el resto de dependencias que quieras de la siguiente lista:

  • serde.
  • serde_json.
  • json_patch.
  • thiserror.

El Cargo.toml se debería contener algo como esto:

[package]
name = "kore_contract"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "=1.0.198", features = ["derive"] }
serde_json = "=1.0.116"
json-patch = "=1.2"
thiserror = "=1.0"
# Note: Change the tag label to the appropriate one
kore-contract-sdk = { git = "https://github.com/kore-ledger/kore-contract-sdk.git", branch = "main"}

Escribiendo el contrato

El siguiente contrato no tiene una lógica complicada ya que ese aspecto depende de las necesidades del propio contrato, pero sí que contiene un amplio abanico de los tipos que se pueden usar y cómo se deben usar. Dado que la compilación la realizará el nodo, debemos escribir el contrato completo en el archivo lib.rs.

En nuestro caso, iniciaremos el contrato especificando los paquetes que vamos a utilizar.

use kore_contract_sdk as sdk;
use serde::{Deserialize, Serialize};

A continuación, es necesario especificar la estructura de datos que representará el estado de nuestros sujetos así como la familia de eventos que recibiremos.

#[derive(Serialize, Deserialize, Clone)]
struct State {
    pub text: String,
    pub value: u32,
    pub array: Vec<String>,
    pub boolean: bool,
    pub object: Object,
}

#[derive(Serialize, Deserialize, Clone)]
struct Object {
    number: f32,
    optional: Option<i32>,
}

#[derive(Serialize, Deserialize)]
enum StateEvent {
    ChangeObject {
        obj: Object,
    },
    ChangeOptional {
        integer: i32,
    },
    ChangeAll {
        text: String,
        value: u32,
        array: Vec<String>,
        boolean: bool,
        object: Object,
    },
}

A continuación definimos la función de entrada al contrato, el equivalente a la función main. Es importante que esta función tenga siempre el mismo nombre que el especificado aquí, ya que es el identificador con el que la máquina evaluadora intentará ejecutarla, produciendo un error si no se encuentra.

#[no_mangle]
pub unsafe fn main_function(state_ptr: i32, event_ptr: i32, is_owner: i32) -> u32 {
    sdk::execute_contract(state_ptr, event_ptr, is_owner, contract_logic)
}

Esta función debe ir siempre acompañada del atributo #[no_mangle] y sus parámetros de entrada y salida deben coincidir también con los del ejemplo. En concreto, esta función recibirá los punteros a los datos de entrada, que serán procesados por la función SDK. Como salida, se generará un nuevo puntero al resultado del contrato que, como se ha mencionado anteriormente, es obtenido por el SDK y no por el programador.

Por último, especificamos la lógica de nuestro contrato, que puede estar definida por tantas funciones como deseemos. Preferiblemente se destacará una función principal, que será la que ejecutará como callback la función execute_contract del SDK.

fn contract_logic(
    context: &sdk::Context<State, StateEvent>,
    contract_result: &mut sdk::ContractResult<State>,
) {
    let state = &mut contract_result.final_state;
    match &context.event {
        StateEvent::ChangeObject { obj } => {
            state.object = obj.to_owned();
        }
        StateEvent::ChangeOptional { integer } => state.object.optional = Some(*integer),
        StateEvent::ChangeAll {
            text,
            value,
            array,
            boolean,
            object,
        } => {
            state.text = text.to_string();
            state.value = *value;
            state.array = array.to_vec();
            state.boolean = *boolean;
            state.object = object.to_owned();
        }
    }
    contract_result.success = true;
    contract_result.approval_required = true;
}

Esta función principal recibe los datos de entrada del contrato encapsulados en una instancia de la estructura Context del SDK. También recibe una referencia mutable al resultado del contrato que contiene el estado final, originalmente idéntico al estado inicial, y las flags de aprobación requerida y ejecución correcta, contract_result.approval_required y contract_result.success, respectivamente. Nótese cómo, además de modificar el estado en función del evento recibido, hay que modificar las flags anteriores. Con la primera especificaremos que el contrato acepta el evento y los cambios que propone para el estado actual del sujeto, lo que se traducirá en el SDK generando un JSON_PATCH con las modificaciones necesarias para transitar del estado inicial al obtenido. La segunda flag, por su parte, nos permite indicar condicionalmente si consideramos que el evento debe ser aprobado o no.

Testeando el contrato

Al ser código Rust, podemos crear una batería de pruebas unitarias en el propio código del contrato para comprobar su rendimiento utilizando los recursos del propio lenguaje. También sería posible especificarlos en un archivo diferente.

// Testing Change Object
#[test]
fn contract_test_change_object() {
    let initial_state = State {
        array: Vec::new(),
        boolean: false,
        object: Object {
            number: 0.5,
            optional: None,
        },
        text: "".to_string(),
        value: 24,
    };
    let context = sdk::Context {
        initial_state: initial_state.clone(),
        event: StateEvent::ChangeObject {
            obj: Object {
                number: 21.70,
                optional: Some(64),
            },
        },
        is_owner: false,
    };
    let mut result = sdk::ContractResult::new(initial_state);
    contract_logic(&context, &mut result);
    assert_eq!(result.final_state.object.number, 21.70);
    assert_eq!(result.final_state.object.optional, Some(64));
    assert!(result.success);
    assert!(result.approval_required);
}

// Testing Change Optional
#[test]
fn contract_test_change_optional() {
    let initial_state = State {
        array: Vec::new(),
        boolean: false,
        object: Object {
            number: 0.5,
            optional: None,
        },
        text: "".to_string(),
        value: 24,
    };
    // Testing Change Object
    let context = sdk::Context {
        initial_state: initial_state.clone(),
        event: StateEvent::ChangeOptional { integer: 1000 },
        is_owner: false,
    };
    let mut result = sdk::ContractResult::new(initial_state);
    contract_logic(&context, &mut result);
    assert_eq!(result.final_state.object.optional, Some(1000));
    assert_eq!(result.final_state.object.number, 0.5);
    assert!(result.success);
    assert!(result.approval_required);
}

// Testing Change All
#[test]
fn contract_test_change_all() {
    let initial_state = State {
        array: Vec::new(),
        boolean: false,
        object: Object {
            number: 0.5,
            optional: None,
        },
        text: "".to_string(),
        value: 24,
    };
    // Testing Change Object
    let context = sdk::Context {
        initial_state: initial_state.clone(),
        event: StateEvent::ChangeAll {
            text: "Kore_contract_test_all".to_string(),
            value: 2024,
            array: vec!["Kore".to_string(), "Ledger".to_string(), "SL".to_string()],
            boolean: true,
            object: Object {
                number: 0.005,
                optional: Some(2024),
            },
        },
        is_owner: false,
    };
    let mut result = sdk::ContractResult::new(initial_state);
    contract_logic(&context, &mut result);
    assert_eq!(
        result.final_state.text,
        "Kore_contract_test_all".to_string()
    );
    assert_eq!(result.final_state.value, 2024);
    assert_eq!(
        result.final_state.array,
        vec!["Kore".to_string(), "Ledger".to_string(), "SL".to_string()]
    );
    assert_eq!(result.final_state.boolean, true);
    assert_eq!(result.final_state.object.optional, Some(2024));
    assert_eq!(result.final_state.object.number, 0.005);
    assert!(result.success);
    assert!(result.approval_required);
}

Como puede ver, lo único que necesita hacer para crear una prueba válida es definir manualmente un estado inicial y un evento entrante en lugar de utilizar la función ejecutora del SDK, que sólo puede ser ejecutada correctamente por Kore. Una vez definidas las entradas, hacer una llamada a la función principal de la lógica del contrato debería ser suficiente.

Una vez probado el contrato, está listo para ser enviado a Kore como se indica en la sección de introducción. Tenga en cuenta que no es necesario enviar las pruebas del contrato a los nodos Kore. De hecho, enviarlas supondrá un mayor uso de bytes del fichero codificado y, en consecuencia, al estar almacenado en el gobierno, un mayor consumo de bytes del mismo.