Saltar al contenido principal

Trabajar con especificaciones de contrato en Java, Python y PHP

Introducción

Los contratos inteligentes de Soroban son herramientas poderosas para construir aplicaciones descentralizadas en la red Stellar. Para interactuar con estos contratos de manera efectiva, es crucial entender sus especificaciones y cómo utilizarlas en tu lenguaje de programación de elección.

Una especificación de contrato típica (espec) incluye:

  1. Tipos de datos utilizados por el contrato
  2. Definiciones de función con sus entradas y salidas
  3. Tipos de error que el contrato puede devolver

Estos detalles guían cómo interactúas con el contrato, independientemente del lenguaje de programación que estés utilizando.

Requisitos previos

Antes de sumergirte en las interacciones con el contrato, asegúrate de tener lo siguiente:

Para esta guía, nos centraremos en los SDK de Java, Python, y PHP como referencia, pero los conceptos también pueden aplicarse a otros lenguajes.

¿Qué son las especificaciones de contrato?

Una especificación de contrato es como un ABI (Interfaz Binaria de Aplicación) en Ethereum. Es una descripción estandarizada de la interfaz de un contrato inteligente, típicamente en formato JSON. Define las funciones del contrato, estructuras de datos, eventos y errores de una manera que las aplicaciones externas pueden entender y usar.

Esta especificación actúa como un puente crucial entre el contrato inteligente y las aplicaciones cliente, permitiendo que interactúen sin necesidad de conocer los detalles internos de implementación del contrato.

Generación de especificaciones de contrato

La CLI de Stellar proporciona un comando para generar una especificación de contrato a partir del código fuente de un contrato. Este proceso es fácil pero requiere que tengas el binario Wasm del contrato.

A veces, es posible que no tengas acceso al código fuente del contrato o la capacidad de compilarlo. En tales casos, debes usar el comando stellar contract fetch para descargar el binario Wasm del contrato y generar la especificación.

Finalmente, usamos el comando stellar bindings para generar la especificación del contrato a partir del binario Wasm.

Obteniendo el binario del contrato

stellar contract fetch --network-passphrase 'Test SDF Network ; September 2015' --rpc-url https://soroban-testnet.stellar.org --id CONTRACT_ID --out-file contract.wasm

Generando la especificación del contrato a partir de Wasm

stellar contract bindings json --wasm contract.wasm > abi.json

Entendiendo la especificación del contrato

La especificación ABI (Interfaz Binaria de Aplicación) para contratos inteligentes Stellar incluye varios componentes clave que definen cómo interactuar con el contrato. Examinemos estos en detalle con ejemplos:

  1. Funciones: Las funciones se definen con su nombre, entradas y salidas. Representan los métodos llamables del contrato. Pueden usarse para escribir datos en el contrato y leer datos del contrato.

    Ejemplo:

    {
    "type": "function",
    "name": "mint",
    "inputs": [
    {
    "name": "contract",
    "value": { "type": "address" }
    },
    {
    "name": "minter",
    "value": { "type": "address" }
    },
    {
    "name": "to",
    "value": { "type": "address" }
    },
    {
    "name": "amount",
    "value": { "type": "i128" }
    }
    ],
    "outputs": [
    {
    "type": "result",
    "value": { "type": "tuple", "elements": [] },
    "error": { "type": "error" }
    }
    ]
    }

    Esto define una función mint que toma cuatro parámetros y devuelve ya sea una tupla vacía o un error. Fíjate en el tipo de cada parámetro: address para las direcciones de cuentas Stellar, i128 para enteros de 128 bits, etc.

  2. Estructuras: Las estructuras definen tipos de datos complejos con múltiples campos.

    Ejemplo:

    {
    "type": "struct",
    "name": "ClaimableBalance",
    "fields": [
    {
    "name": "amount",
    "value": { "type": "i128" }
    },
    {
    "name": "claimants",
    "value": {
    "type": "vec",
    "element": { "type": "address" }
    }
    },
    {
    "name": "time_bound",
    "value": {
    "type": "custom",
    "name": "TimeBound"
    }
    },
    {
    "name": "token",
    "value": { "type": "address" }
    }
    ]
    }

    Esto define una estructura ClaimableBalance con cuatro campos.

  3. Uniones: Las uniones representan variables que pueden ser de varios tipos.

    Ejemplo:

    {
    "type": "union",
    "name": "DataKey",
    "cases": [
    {
    "name": "Init",
    "values": []
    },
    {
    "name": "Balance",
    "values": []
    }
    ]
    }

    Esto define una unión DataKey que puede ser Init o Balance.

  4. Tipos personalizados: Los tipos personalizados se refieren a otros tipos definidos en la ABI.

    Ejemplo:

    {
    "name": "time_bound",
    "value": {
    "type": "custom",
    "name": "TimeBound"
    }
    }

    Esto se refiere a un tipo TimeBound personalizado definido en otro lugar de la ABI.

  5. Tipos de vector: Los vectores representan matrices de un tipo específico.

    Ejemplo:

    {
    "name": "claimants",
    "value": {
    "type": "vec",
    "element": { "type": "address" }
    }
    }

    Esto define un vector de direcciones.

  6. Tipos primitivos: Estos incluyen tipos básicos como i128 (entero de 128 bits), u64 (entero sin signo de 64 bits), address, etc.

    Ejemplo:

    {
    "name": "amount",
    "value": { "type": "i128" }
    }

Estas especificaciones son cruciales para codificar y decodificar datos al interactuar con el contrato. Por ejemplo:

  • Al llamar a la función mint, debes proporcionar cuatro parámetros: tres direcciones y un entero de 128 bits.
  • Si una función devuelve un ClaimableBalance, esperarías recibir una estructura con una cantidad (i128), un vector de direcciones (claimants), un objeto TimeBound, y una dirección (token).
  • Si una función podría devolver un Error, lo más probable es que falle en la simulación y no necesitarás decodificar el resultado.

Tipos en Soroban

Antes de sumergirnos en la interacción con los contratos inteligentes de Stellar, es importante notar que Soroban tiene su propio conjunto de tipos que se utilizan para interactuar con los contratos según lo descrito en esta guía. Aquí hay algunos de los tipos comunes:

  • u32: Entero sin signo de 32 bits
  • u64: Entero sin signo de 64 bits
  • i32: Entero con signo de 32 bits
  • i64: Entero con signo de 64 bits
  • u128: Entero sin signo de 128 bits
  • i128: Entero con signo de 128 bits
  • bool: Booleano
  • string: Cadena codificada en UTF-8
  • vec: Arreglo de longitud variable
  • address: Dirección de cuenta Stellar
  • map: Mapa clave-valor
  • symbol: Una cadena pequeña utilizada principalmente para nombres de función y claves de mapa

En esta guía y los SDKs, estos tipos se representan como ScU32, ScU64, ScI32, ScI64, ScU128, ScI128, ScBool, ScString, ScVec, ScAddress, ScMap, y ScSymbol respectivamente.

Cada otro tipo complejo puede derivarse utilizando estos tipos básicos, pero estos tipos no se traducen realmente a valores en los lenguajes de programación. Los SDKs de Stellar proporcionan clases de ayuda para trabajar con estos tipos.

Trabajando con tipos nativos de Soroban

Una de las tareas más comunes al trabajar con contratos inteligentes de Stellar es convertir entre tipos de contrato inteligente de Stellar y tipos nativos en tu lenguaje de programación. En esta guía, revisaremos algunas conversiones comunes y mostraremos cómo pueden usarse para invocar contratos con la ayuda de la especificación del contrato.

En la mayoría de los SDKs, se usa la clase o función ScVal para convertir entre tipos Soroban y tipos nativos.

nota

El bloque de código JSON muestra la especificación del contrato, mientras que los bloques de código en Rust muestran el contrato para cada ejemplo.

1. Invocar una función de contrato sin parámetros

Usaremos la función increment del contrato de incremento de ejemplo para ejemplificar esto. La función increment no toma parámetros y aumenta el contador en 1.

En este escenario, no es necesario realizar conversiones y pasar el valor null como argumentos del contrato es suficiente en la mayoría de los SDKs.

#[contractimpl]
impl IncrementContract {
/// Increment increments an internal counter, and returns the value.
pub fn increment(env: Env) -> u32 {
// Get the current count.
let mut count: u32 = env.storage().instance().get(&COUNTER).unwrap_or(0); // If no value set, assume 0.
log!(&env, "count: {}", count);
// Increment the count.
count += 1;

// Save the count.
env.storage().instance().set(&COUNTER, &count);
env.storage().instance().extend_ttl(50, 100);

// Return the count to the caller.
count
}
}
información

Los ejemplos subsiguientes mostrarán bloques de código solo para usar la especificación del contrato para reducir la redundancia.

2. Invocar una función de contrato con uno o más parámetros

Generalmente, esto implica pasar un array nativo (no un ScVec) de parámetros a la función del contrato.

Usaremos la función hello del contrato Hello World de ejemplo para ejemplificar esto.

En este escenario, necesitamos convertir el parámetro de cadena a un tipo ScString antes de pasarlo al contrato. En este escenario, necesitamos convertir el parámetro de cadena a un tipo ScString antes de pasarlo al contrato.

Este proceso es conveniente utilizando la clase o función ScVal en la mayoría de los SDKs.

#[contract]
pub struct HelloContract;

#[contractimpl]
impl HelloContract {
pub fn hello(env: Env, to: String) -> Vec<String> {
vec![&env, String::from_str(&env, "Hello"), to]
}
}

3. Obteniendo respuestas de los contratos

Los datos devueltos de los contratos también están en formato ScVal y necesitan ser convertidos a tipos nativos en tu lenguaje de programación.

Todavía usaremos la función hello del contrato Hello World de ejemplo para ejemplificar esto.

En este escenario, necesitamos convertir el valor devuelto de un ScVec de tipo ScString a un array de string antes de hacer uso de él. En este escenario, necesitamos convertir el valor devuelto de un ScVec de tipo ScString a un array de string antes de hacer uso de él.

Pasos:

  • Extraer un ScVec del valor de retorno
  • Extraer cada ScString del ScVec
  • Convertir cada ScString a una cadena nativa

Este proceso es conveniente usando la clase o función ScVal en la mayoría de los SDKs.

Preferiblemente, para recuperar este valor, necesitamos usar el método RPC getTransaction utilizando el hash de respuesta de la transacción que invocó la función del contrato.

#[contract]
pub struct HelloContract;

#[contractimpl]
impl HelloContract {
pub fn hello(env: Env, to: String) -> Vec<String> {
vec![&env, String::from_str(&env, "Hello"), to]
}
}

Trabajando con tipos de datos complejos

Como se describe en esta guía, hay algunas otras variantes de estructuras de datos soportadas por Soroban. Ellas son

  • Struct con campos nombrados
  • Struct con campos no nombrados
  • Enum (variantes unitarias y de tupla)
  • Enum (variantes enteras)

Veremos cómo estas variantes se traducen a la especificación y cómo construirlas en los diferentes SDKs.

Struct con campos nombrados

Las estructuras con valores nombrados cuando se convierten a ABI o especificación se representan como un ScMap donde cada valor tiene la clave en ScSymbol y el valor en el tipo subyacente.

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct State {
pub count: u32,
pub last_incr: u32,
}

Struct con campos no nombrados

Las estructuras con valores no nombrados cuando se convierten a ABI o especificación se representan como un ScVal donde cada valor tiene el tipo subyacente.

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct State(pub u32, pub u32);

Enum (variantes unitarias y de tupla)

Los enums generalmente se representan con ScVec, sus tipos unitarios se representan como ScSymbol y sus variantes de tupla se representan como los tipos subyacentes.

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Enum {
A,
B(u32),
}

Enum (variantes enteras)

Los enums generalmente se representan con ScVec, la variante entera no tiene claves, así que es solo un ScVec del tipo subyacente.

#[contracttype]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum Enum {
A = 1,
B = 2,
}

Un ejemplo complejo

Usaremos el ejemplo del contrato de bloqueo de tiempo para mostrar cómo interactuar con un contrato que tiene tipos de datos complejos.

Este ejemplo utiliza una estructura TimeBound que tiene un enum TimeBoundKind como uno de sus campos, que son parámetros para la función deposit. Este ejemplo combina la mayoría de los conceptos que hemos discutido hasta ahora.

#[derive(Clone)]
#[contracttype]
pub enum TimeBoundKind {
Before,
After,
}

#[derive(Clone)]
#[contracttype]
pub struct TimeBound {
pub kind: TimeBoundKind,
pub timestamp: u64,
}

#[contracttype]
#[contractimpl]
impl ClaimableBalanceContract {
pub fn deposit(
env: Env,
from: Address,
token: Address,
amount: i128,
claimants: Vec<Address>,
time_bound: TimeBound,
) {}
}

Lectura de eventos del contrato

Leer eventos del contrato es similar a leer resultados de transacciones. Puedes usar el método RPC getEvents para obtener la lista de eventos asociados con un contrato.

Una convención común es que cadenas pequeñas como nombres de función, claves de enum y temas de eventos se representan como ScSymbol en la especificación del contrato.

Sin embargo, los temas de eventos pueden ser de cualquier tipo scval dependiendo de la implementación del contrato.

En el siguiente ejemplo, codificaremos el mint a ScSymbol antes de consultarlo, y también codificaremos las direcciones a ScAddress. Incluso después de obtener el evento, necesitaremos analizar los temas y valores para recuperar los valores reales de xdr base 64 a sus tipos correspondientes antes de convertirlos a tipos nativos.

let address_1: &Address = "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".into();
let address_2: &Address = "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".into();
let count: i128 = 1;
env.events()
.publish((symbol_short!("mint"), address_1, address_2), count);

Conclusión

Como hemos visto, trabajar con contratos inteligentes de Soroban en diferentes lenguajes de programación no es ciencia espacial, pero requiere atención cuidadosa a los detalles. Los puntos clave son:

  • Siempre comienza con un sólido entendimiento de la especificación de tu contrato
  • Conviértete en un experto en convertir entre tipos nativos y las peculiares estructuras de datos de Soroban
  • No te intimidis por los tipos de datos complejos - son solo acertijos esperando ser resueltos
  • Cuando tengas dudas, consulta la documentación de tu SDK para las sutilezas específicas del lenguaje