dusk-data-driver 0.2.0

Data driver contract interface to support json serialization
Documentation

Data Drivers Overview

In this module, we provide data drivers whose task is to ease communication with Dusk smart contracts for JavaScript clients. By a data driver we mean here a separate Web Assembly module which provides methods converting JavaScript arguments into a form which is understood by Dusk smart contracts, and also converting Dusk smart contracts outputs into a form which is understood by JavaScript.

Data drivers do not call Dusk smart contracts. A particular data driver is aware of methods of the corresponding Dusk smart contract, yet it does not call them.

Each data driver has its assigned Dusk smart contract, yet it is important to be aware that data driver for a contract does not call its methods, although it is aware of them and of their arguments and outputs.

Module data-driver

Module data-driver contains core code to be used by all drivers. It can be thought of as a framework to be used by concrete drivers, which are written for particular contracts. Module data-driver makes it easy for the authors of concrete drivers to create a driver. The authors need just to implement one trait, the complexity is delegated to module data-driver.

Module data-driver provides functionality which make it easy to call drivers from both Rust and JavaScript. In order to achieve that it does not use wasm-bindgen, but rather provides a set methods which are easy to call from modules written in both languages. The methods are declared with no argument mangling, making them callable from within any language which supports "C"-like calling conventions.

The following methods are provided by module data-driver:


alloc(size: usize) -> *mut u8

dealloc(ptr: *mut u8, size: usize)

get_last_error(...) -> ErrorCode

encode_input_fn(...) -> ErrorCode

decode_input_fn(...) -> ErrorCode

decode_output_fn(...) -> ErrorCode

decode_event(...) -> ErrorCode

get_schema(...) -> ErrorCode

get_version(...) -> ErrorCode

In the following sections we will describe the above methods grouped by their areas of responsibility.

Memory Allocation Methods

Methods alloc and dealloc are needed for both JavaScript and Rust callers (and callers from other languages as well) to pass parameters to other methods. Typical driver method accepts the following parameters:

    fn_name_ptr: *mut u8,
    fn_name_size: usize,
    data_ptr: *mut u8,
    data_size: usize,
    out_ptr: *mut u8,
    out_buf_size: usize,

In order to pass method name, caller must allocate a buffer, copy the name to it, and pass a pointer to the buffer and its length. Similarly, with input data and with output data. To allocate a buffer, caller needs to use alloc method which will return a buffer offset in Wasm memory. Note that alloc and dealloc methods deal with offsets, they do not provide absolute memory locations but rather offsets which need to be added to the pointer to Wasm memory segment. This can be easily done in both JavaScript and Rust. First, Wasm memory pointer needs to be obtained, then, alloc is called and the result of its call needs to be added to memory pointer. Only then such pointer can be used as buffer for arguments for the remaining driver methods. Once the buffer is no longer needed, dealloc should be used to free memory.

Argument encoding and decoding methods

Here by encoding we mean converting the arguments from JSON to rkyv, a serialization format which Dusk smart contracts understand. By decoding we mean the opposite conversion.

There are four methods in this group, so lets consider each one of them in turn:

encode_input_fn

Parameters:

    fn_name_ptr: *mut u8,
    fn_name_size: usize,
    json_ptr: *mut u8,
    json_size: usize,
    out_ptr: *mut u8,
    out_buf_size: usize,

This method accepts method name and a buffer containing arguments in JSON form. On return, it fills out the output buffer with output length (first 4 bytes, as a big-endian 32-bit number), and then it fills out the subsequent bytes of the buffer with rkyv serialization ready to be used when making a call to a given smart contract method. Caller needs to read first 4 bytes of the output buffer first to know the returned data length. Say, first 4 bytes contained number N. The caller then needs to read the remaining N bytes of rkyv serialization returned. Assuming that the method name passed to encode_input_fn was M, the caller can pass the obtained data to smart contract method M as its arguments.

decode_input_fn

Parameters:

    fn_name_ptr: *mut u8,
    fn_name_size: usize,
    rkyv_ptr: *const u8,
    rkyv_len: usize,
    out_ptr: *mut u8,
    out_buf_size: usize,

This method does the opposite to what encode_input_fn does, it converts arguments in form of an rkyv serialization string into JSON. Other calling details are the same as in encode_input_fn.

decode_output_fn

Parameters:

    fn_name_ptr: *mut u8,
    fn_name_size: usize,
    rkyv_ptr: *const u8,
    rkyv_len: usize,
    out_ptr: *mut u8,
    out_buf_size: usize,

This method does the same conversion as decode_input_fn, yet it is performed on method's output rather than input. Other calling details are the same as in encode_input_fn and decode_input_fn.

decode_event

Parameters:

    event_name_ptr: *mut u8,
    event_name_size: usize,
    rkyv_ptr: *const u8,
    rkyv_len: usize,
    out_ptr: *mut u8,
    out_buf_size: usize,

This method converts event from rkyv-serialized form into JSON. Unlike other argument encoding/decoding methods, this method accepts event name rather than method name.

All the above methods make it possible to:

  • convert JSON arguments to rkyv bytes ready to be used in smart contract method calls
  • convert rkyv bytes returned by smart contract method calls into JSON
  • convert events emitted by smart contract into JSON

Concrete smart contract driver developer needs only to implement one trait named ConvertibleContract, in order to implement the above functionality. Note that ConvertibleContract is much simplified compared to the above, more about it in the following sections.

Miscellaneous methods

Method get_last_error() allows obtaining detailed error description when ErrorCode::OperationError is returned

Method get_schema() should return JSON schema describing all contract calls. This feature is not used yet and in current implementation an empty string is returned.

Method get_version() returns versions of the driver. Currently, versioning is up to the discretion of driver's author.

Trait ConvertibleContract - to be used by drivers' creators

Fortunately, the above functions with their rather complex parameters are to be used only internally, by this module (i.e. module data-driver) and by the infrastructure. Regular smart contract authors are dealing only with a much simplified trait named ConvertibleContract. The trait is a Rust concept and its methods are to be implemented in Rust, yet thanks to the architecture enabled by the module data-driver, the driver which implements ConvertibleContract and uses module data-driver will provide arguments and return values translations to both JavaScript and Rust users.

Smart contract author needs to implement the following methods of trait ConvertibleContract:

    fn encode_input_fn(&self, fn_name: &str, json: &str) -> Result<Vec<u8>, Error>;
    fn decode_input_fn(&self, fn_name: &str, rkyv: &[u8]) -> Result<JsonValue, Error>;
    fn decode_output_fn(&self, fn_name: &str, rkyv: &[u8]) -> Result<JsonValue, Error>;
    fn decode_event(&self, event_name: &str, rkyv: &[u8]) -> Result<JsonValue, Error>;
    fn get_schema(&self) -> String;
    fn get_version(&self) -> &'static str

As you can see, thanks to the fact that the trait is a Rust concept and confined within the the realm of a single language, the parameters are much simplified. Module data-driver provides a set of conversion methods which make implementing the trait ConvertibleContract easy. To such helper functions, provided by module data-driver, belong:

    json_to_rkyv
    rkyv_to_json
    from_rkyv
    json_to_rkyv_u64
    rkyv_to_json_u64  
    json_to_rkyv_pair_u64
    rkyv_to_json_pair_u64

Example ConvertibleContract implementation may look as follows: (assuming that the contract has only one method deposit_to and one event Withdrawal)

impl ConvertibleContract for ContractDriver {
    fn encode_input_fn(
        &self,
        fn_name: &str,
        json: &str,
    ) -> Result<Vec<u8>, Error> {
        match fn_name {
            "deposit_to" => {
                json_to_rkyv::<(EVMAddress, u64, u64, Vec<u8>)>(json)
            }
            // Unsupported
            name => Err(Error::Unsupported(format!("fn_name {name}"))),
        }
    }

    fn decode_input_fn(
        &self,
        fn_name: &str,
        rkyv: &[u8],
    ) -> Result<JsonValue, Error> {
        match fn_name {
            "deposit_to" => {
                rkyv_to_json::<(EVMAddress, u64, u64, Vec<u8>)>(rkyv)
            }
            // Unsupported
            name => Err(Error::Unsupported(format!("fn_name {name}"))),
        }
    }

    fn decode_output_fn(
        &self,
        fn_name: &str,
        rkyv: &[u8],
    ) -> Result<JsonValue, Error> {
        match fn_name {
            "deposit_to" => Ok(JsonValue::Null),
            "finalize_withdrawal" => Ok(JsonValue::Null),
            // Unsupported
            name => Err(Error::Unsupported(format!("fn_name {name}"))),
        }
    }

    fn decode_event(
        &self,
        event_name: &str,
        rkyv: &[u8],
    ) -> Result<JsonValue, Error> {
        match event_name {
            "Withdrawal" => {
                rkyv_to_json::<events::Withdrawal>(rkyv)
            }
            event => Err(Error::Unsupported(format!("event {event}"))),
        }
    }

    fn get_schema(&self) -> String {
        "".to_string()
    }
}

When your ConvertibleContract implementation is compiled to Wasm, it is injected into module data-driver, which is a part of your Wasm. The module data-driver does all the heavy-lifting, yet it calls your ConvertibleContract implementation to do the actual conversion. Your implementation, in turn, uses helper functions provided by the module, thus, forming only a thin slice inside this sandwich design. Such architecture makes writing drivers easy.

Examples of equivalent JavaScript and Rust arguments

What seems to be sometimes hard is to come up with JavaScript equivalents of some Rust argument types. Let's consider the following variants of Dusk smart contract argument types:

Single primitive argument

Consider the case of a single primitive argument. Let's say we have a function accepting one argument of type dusk_core::signatures::bls::PublicKey. In such case, example JSON value could be as follows:

tCR9c1pQU1jC5QgmRi3JRb2g1Rhtrc6AxT24VQPtMY3wrsuRrnMBMP6wSoKWXH2opKTeCm5aniEG2HH8ATcUHzeWe6814e8qdECGvLLZvhaRKsi7MJgLAA33PiWZ4b6ptNt

Single object/structural argument

In case of a single object argument of a function, a Rust struct, an example JSON representation could be as follows:

{
    "chain_id": 1,
    "keys": {
      "account": "tCR9c1pQU1jC5QgmRi3JRb2g1Rhtrc6AxT24VQPtMY3wrsuRrnMBMP6wSoKWXH2opKTeCm5aniEG2HH8ATcUHzeWe6814e8qdECGvLLZvhaRKsi7MJgLAA33PiWZ4b6ptNt",
      "owner": {
        "Account": "tCR9c1pQU1jC5QgmRi3JRb2g1Rhtrc6AxT24VQPtMY3wrsuRrnMBMP6wSoKWXH2opKTeCm5aniEG2HH8ATcUHzeWe6814e8qdECGvLLZvhaRKsi7MJgLAA33PiWZ4b6ptNt"
      }
    },
    "signature": {
      "account": "7kP8oaxopsWi7g6kNGtX3PHVekMF8RKRRx74tqoo1xLmh2zGVN2FmJ5EFg7UJV9stk",
      "owner": "7kP8oaxopsWi7g6kNGtX3PHVekMF8RKRRx74tqoo1xLmh2zGVN2FmJ5EFg7UJV9stk"
    },
    "value": "4014086097495"
}

The above JSON structure corresponds to the following Rust struct:

pub struct Stake {
    chain_id: u8,
    keys: StakeKeys,
    value: u64,
    signature: DoubleSignature,
}

where StakeKeys is:

pub struct StakeKeys {
    pub account: BlsPublicKey,
    pub owner: StakeFundOwner,

and DoubleSignature is:

pub struct DoubleSignature {
    pub account: BlsSignature,
    pub owner: BlsSignature,
}

As you can see, JSON representation does not include object name, only its named members.

Multiple primitive arguments

In case of multiple primitive elements, say, a function which, for example, accepts two arguments, say, u64 and u32. An example JSON could look as follows:

[ 22, 33 ]

Multiple arguments, which in Rust will have different parameter names, are represented in JSON as members of an array. Parameters' names are not represented in JSON at all.

Multiple mixed primitive/structural arguments

In case smart contract method accepts a mix of structural and primitive arguments, or in general, multiple arguments, they are represented in JSON as an array. Assuming we have a function which accepts a struct Stake as above, followed by a u32 number, an example JSON representation could be as follows:

[
  {
    "chain_id": 1,
    "keys": {
      "account": "tCR9c1pQU1jC5QgmRi3JRb2g1Rhtrc6AxT24VQPtMY3wrsuRrnMBMP6wSoKWXH2opKTeCm5aniEG2HH8ATcUHzeWe6814e8qdECGvLLZvhaRKsi7MJgLAA33PiWZ4b6ptNt",
      "owner": {
        "Account": "tCR9c1pQU1jC5QgmRi3JRb2g1Rhtrc6AxT24VQPtMY3wrsuRrnMBMP6wSoKWXH2opKTeCm5aniEG2HH8ATcUHzeWe6814e8qdECGvLLZvhaRKsi7MJgLAA33PiWZ4b6ptNt"
      }
    },
    "signature": {
      "account": "7kP8oaxopsWi7g6kNGtX3PHVekMF8RKRRx74tqoo1xLmh2zGVN2FmJ5EFg7UJV9stk",
      "owner": "7kP8oaxopsWi7g6kNGtX3PHVekMF8RKRRx74tqoo1xLmh2zGVN2FmJ5EFg7UJV9stk"
    },
    "value": "4014086097495"
  }, 
  33
]

In the above example, we can see JSON representation of a Stake object and a number 33, being passed as two named parameters to a smart contract method. Parameter names are ignored in serialization formats, as Dusk smart contract methods assume that all input function parameters form a tuple. A tuple is represented as an array in JSON.

Output values

Output values are always single values, and the above information about JSON representation of Rust arguments is valid also for output values. In Rust, multiple output values can be embedded into a tuple, forming a single aggregated value. Such value will be represented in JSON as an array of elements, each of which will be represented in JSON in an analogous way to the above examples.