Skip to main content

Work with contract specs in Java, Python, and PHP

Introduction

Soroban smart contracts are powerful tools for building decentralized applications on the Stellar network. To interact with these contracts effectively, it's crucial to understand their specifications and how to use them in your programming language of choice.

A typical contract specification (spec) includes:

  1. Data types used by the contract
  2. Function definitions with their inputs and outputs
  3. Error types that the contract may return

These details guide how you interact with the contract, regardless of the programming language you're using.

Prerequisites

Before diving into contract interactions, ensure you have the following:

  • Stellar CLI (stellar) installed
  • A Soroban-compatible SDK for your programming language. View the list of available SDKs to find one that suits your needs
  • Access to a Stellar RPC server (local or on a test network)

For this guide, we will focus on the Java, Python, and PHP SDKs for reference, but the concepts can also be applied to other languages.

What are contract specs?

A contract spec is just like an ABI (Application Binary Interface) in Ethereum. It is a standardized description of a smart contract's interface, typically in JSON format. It defines the contract's functions, data structures, events, and errors in a way that external applications can understand and use.

This specification serves as a crucial bridge between the smart contract and client applications, enabling them to interact without needing to know the contract's internal implementation details.

Generating contract specs

The Stellar CLI provides a command to generate a contract spec from a contract's source code. This process is easy but requires you to have the Wasm binary of the contract.

Sometimes, you may not have access to the contract's source code or the ability to compile it. In such cases, you must use the stellar contract fetch command to download the contract's Wasm binary and generate the spec.

Finally, we use the stellar bindings command to generate the contract spec from the Wasm binary.

Fetching the contract binary

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

Generating the contract spec from Wasm

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

Understanding the contract specification

The ABI (Application Binary Interface) specification for Stellar smart contracts includes several key components that define how to interact with the contract. Let's examine these in detail with examples:

  1. Functions: Functions are defined with their name, inputs, and outputs. They represent the callable methods of the contract. They can be used for writing data to the contract and reading data from the contract.

    Example:

    {
    "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" }
    }
    ]
    }

    This defines a mint function that takes four parameters and returns either an empty tuple or an error. Notice the type of each parameter: address for Stellar account addresses, i128 for 128-bit integers, etc.

  2. Structs: Structs define complex data types with multiple fields.

    Example:

    {
    "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" }
    }
    ]
    }

    This defines a ClaimableBalance struct with four fields.

  3. Unions: Unions represent variables that can be one of several types.

    Example:

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

    This defines a DataKey union that can be either Init or Balance.

  4. Custom Types: Custom types refer to other defined types in the ABI.

    Example:

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

    This refers to a custom TimeBound type defined elsewhere in the ABI.

  5. Vector Types: Vectors represent arrays of a specific type.

    Example:

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

    This defines a vector of addresses.

  6. Primitive Types: These include basic types like i128 (128-bit integer), u64 (64-bit unsigned integer), address, etc.

    Example:

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

These specifications are crucial for encoding and decoding data when interacting with the contract. For example:

  • When calling the mint function, you must provide four parameters: three addresses and a 128-bit integer.
  • If a function returns a ClaimableBalance, you would expect to receive a struct with an amount (i128), a vector of addresses (claimants), a TimeBound object, and an address (token).
  • If a function could return an Error, it will most likely fail at simulation and you won't need to decode the result.

Soroban types

Before we dive into interacting with Stellar smart contracts, it is important to note that Soroban has its own set of types that are used to interact with the contracts as described in this guide. Here are some of the common types:

  • u32: Unsigned 32-bit integer
  • u64: Unsigned 64-bit integer
  • i32: Signed 32-bit integer
  • i64: Signed 64-bit integer
  • u128: Unsigned 128-bit integer
  • i128: Signed 128-bit integer
  • bool: Boolean
  • string: UTF-8 encoded string
  • vec: Variable-length array
  • address: Stellar account address
  • map: Key-value map
  • symbol: A small string used mainly for function names and map keys

In this guide and the SDKs, these types are represented as ScU32, ScU64, ScI32, ScI64, ScU128, ScI128, ScBool, ScString, ScVec, ScAddress, ScMap, and ScSymbol respectively.

Every other complex type can be derived using these basic types but these types do not really map to values in the programming languages. The Stellar SDKs provide helper classes to work with these types.

Working with native Soroban types

One of the most common tasks when working with Stellar smart contracts is converting between Stellar smart contract types and native types in your programming language. In this guide, we will go over some common conversions and show how they can be used to invoke contracts with the help of the contract spec.

In most SDKs, the ScVal class or function is used to convert between Soroban types and native types.

note

The JSON code block shows the contract spec, while RUST code blocks show the contract for each example.

1. Invoking a contract function with no parameters

We will be using the increment function of the sample increment contract to exemplify this. The increment function takes no parameters and increments the counter by 1.

In this scenario, there is no need for conversions and passing the value null as contract arguments is sufficient in most 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
}
}
info

Subsequent examples will show code blocks for using the contract spec only to reduce redundancy.

2. Invoking a contract function with one or more parameters

Generally, this involves passing in a native array (not a ScVec) of parameters to the contract function.

We will be using the hello function of the sample Hello World contract to exemplify this.

We know from the spec that the hello function takes a string parameter and returns a vector of strings. In this scenario, we need to convert the string parameter to a ScString type before passing it to the contract.

This process is convenient using the ScVal class or function in most 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. Getting responses from contracts

Data returned from contracts is also in ScVal format and need to be converted to native types in your programming language.

We will still be using the hello function of the sample Hello World contract to exemplify this.

We know from the Spec that the hello function takes a string parameter and returns a vec of strings. In this scenario, we need to convert the value returned from an ScVec of ScString type to array of string before making use of it.

Steps:

  • Extract an ScVec from the return value
  • Extract each ScString from the ScVec
  • Convert each ScString to a native string

This process is convenient using the ScVal class or function in most SDKs.

Ideally, to retrieve this value, we need to use the getTransaction RPC method using the response hash of the transaction that invoked the contract function.

#[contract]
pub struct HelloContract;

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

Working with complex data types

As described in this guide, there are some other variants of data structure supported by Soroban. They are

  • Struct with named fields
  • Struct with unnamed fields
  • Enum (Unit and Tuple Variants)
  • Enum (Integer Variants)

We would be looking at how these variants translate to the spec and how to construct them in the different SDKs.

Struct with named fields

Structs with named values when converted to ABI or spec are represented as a ScMap where each value has the key in ScSymbol and the value in the underlying type.

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

Struct with unnamed fields

Structs with unnamed values when converted to ABI or spec are represented as a ScVal where each value has the underlying type.

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

Enum (unit and tuple variants)

Enums are generally represented with ScVec, their unit types are represented as ScSymbol and their tuple variants are represented as the underlying types.

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

Enum (integer variants)

Enums are generally represented with ScVec, the integer variant has no keys so it's just a ScVec of the underlying type.

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

A complex example

Let's use the timelock contract example to show how to interact with a contract that has complex data types.

This example uses a TimeBound struct that has a TimeBoundKind enum as one of its fields, which are parameters to the deposit function. This example combines most of the concepts we have discussed so far.

#[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,
) {}
}

Reading contract events

Reading contract events is similar to reading transaction results. You can use the getEvents RPC method to get the list of events associated with a contract.

One common convention is that small strings like function names, enum keys, and event topics are represented as ScSymbol in the contract spec.

However, event topics can be any scval type depending on the contract implementation.

In the example below, we will be encoding the mint to ScSymbol before querying it, and also encoding the addresses to ScAddress. Even after getting the event, we will need to parse the topics and value to get the actual values again from xdr base 64 to their corresponding types before then converting it to native types.

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);

Wrapping Up

As we've seen, working with Soroban smart contracts across different programming languages isn't rocket science, but it does require some careful attention to detail. The key takeaways:

  • Always start with a solid understanding of your contract's spec
  • Get comfortable with converting between native types and Soroban's quirky data structures
  • Don't be intimidated by complex data types - they're just puzzles waiting to be solved
  • When in doubt, consult your SDK's documentation for language-specific nuances