This tutorial is the continuation of the ADAPT Messenger series. For foundational concepts and initial setup, refer to Part 1.

Enhancing our ADAPT Chat App

We continue the tutorial with the exploration of more advanced features of ADAPT. In this segment, we will cover:

  • techniques for message encryption between packets in the network
  • MUFL code organization techniques
  • built-in MUFL collections
  • error handling
  • address documents

At the end of this walkthrough, reader will become familiar with the basic workflow for implementing distribited systems in ADAPT and get a complete messaging app working with under 300 lines of backend code.

We will also take a look at the front-end part of our application, though not as thorougly as in the first part of the tutorial.

I. Quick refresher

Let’s first talk about our previous iteration of the messenger. This is how the backend MUFL code looked like:

application actor loads libraries
    current_transaction_info,
    identity_proof_document,
    attestation_document,
    browser_attestation_document,
    transaction_message_decoder
    uses transactions
{

    metadef message_type: ($data -> str, $packet_id -> global_id).
    
    trn send_message message: message_type
    {
        // validate the transaction's origin
        current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
    
        // extract recipient packet id from the transaction argument
        recipient_id = message $packet_id. 
    
        // extract the message text
        text = message $data.
    
        return transaction::success [
            transaction::action::send recipient_id (
                $name -> "::actor::receive_message", // specify the name of the transaction that should be invoked in the recipient's packet
                $targ -> text // pass an argument
            )
        ].
    }
    
    trn receive_message message: str
    {
        // validate the transaction's origin
        current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
    
        // extract the sender's packet ID from the transaction envelope
        envelope = current_transaction_info::get_external_envelope_or_abort ().
        sender_id = envelope $from. // envelope is of 'record' type, use reduction to extract the $from field
    
        return transaction::success [
            transaction::action::return_data ("Received a message: " + message + ", from a packet: " + sender_id)
        ].
    }
}
  • We have created an application, that is a blueprint for future ADAPT packets created from it, defining its public API via transactions. This application manages all data related to the current user of the chat system and provides the basic business logic for the interations within the messenger.
  • We have included other functionality from the libraries such as “current_transaction_info”, “transaction_message_decoder” and others by loading them.
  • We have defined message_type that encapsulates two key pieces of information: the packet ID it belongs to and the actual message string.
  • We have also defined our public API for the packet as 2 transactions:
    • send_message -> it takes a message, validates the transaction’s origin, extracts the adressee ID and a text string from the message, then sends it to the adressee.
    • receive_message -> it defines the user’s logic upon receiving a message, that means validating the transaction’s origin, extraction of the meta-information about the message and forwarding it to the front-end by passing a transaction::action::return_data structure to transaction::success.

II. Back-end MUFL Code

The design

Now it’s time for us to draw a clear picture of our desired product, it’s architecture and implementation.
The starting requirements are as follows:

  • End-to-end encryption.
  • Multi-user chat support.
  • Chat invite generation.
  • Joining the chat with the generated invite.
  • Serverless architecture.

A possible solution is to create a decentralized network between users so that each of them keeps track of their contacts and their public encryption keys.

classDiagram
    class User {
        +name: String
        +contacts: Array[Contact]
        +chats: Array[Chat]
    }
    User "1" -- "0 or more" User: contacts
    User "1" -- "0 or more" Chat: chats

Multi-user chat is implemented by broadcasting the messages between users and treating a collection of interconnected P2P chats as a single multi-user channel.

graph TD

    User1[User 1] -->|Sends Message to| ChannelID[Channel X]
    ChannelID -->|Distributes Message to| User2[User 2]
    ChannelID -->|Distributes Message to| User3[User 3]
    
    User2 -->|Receives Message in| ReceivedInChannel2[Channel X]
    User3 -->|Receives Message in| ReceivedInChannel3[Channel X]
    
    subgraph ChannelDetails[Channel Info]
        ChannelUsers[Users: User1, User2, User3]
    end

    ChannelID -.-> ChannelDetails

the goal of this demo is to demonstrate implementation of security-sensitive protocols, noting that the obvious scalability issues arising with this approach are beyond the scope of this tutorial.

this messenger is not, technically, fully decentralized because of the presence of the message broker component. However, we note that after the code is complete, the messenger will not be able to peek into the messanges that users send to each other, because they will be end-to-end encrypted. The development of a fully decentralized message broker is ongoing.

Implementing the basic functionality

Creating a chat channel

As our architecture is somewhat clearer now, let’s start with defining the basic types we will use. These are chats, messages and chat members.

metadef message_t: ($data -> str, $chat_id -> global_id).
metadef member_t: global_id.
metadef chat_t: ($chat_id -> global_id, $chat_name -> str, $members -> member_t(,)).
  • Here, unit_t(,) is a type for a set of unit_t values.
  • Now a message contains it’s text string and the ID of the chat to which it belongs instead of the ID of the packet to which it was sent.
  • A chat is a structure containing of its id, name and the array of its members.

Now that we have defined the basic types, we can proceed to store a chat registry in our actor’s packet.

chats is (global_id ->> chat_t) = (,).

With our preparatory steps complete, let’s move on to the transaction for creating a new chat.

// ---------------------------------------------
//                 Chat creation
// ---------------------------------------------

trn create_chat _: ($chat_name -> chat_name: str)
{
    chat_id = _new_id ("create new chat: " + chat_name).
    chat = ($chat_id -> chat_id, $chat_name -> chat_name, $members -> (_get_container_id(),)).
    chats chat_id -> chat.

    return transaction::success [].
}

Several noteworthy features are used in this example. To begin with, the _: ($chat_name -> chat_name: str) syntax allows us to pass multiple arguments to a transaction by using a record structure. Worth noting, a transaction must always take exactly a single argument. Although unnecessary for this instance, the structure offers flexibility for future enhancements.

The logic is straightforward. A new chat ID is generated based on the provided name, and a new chat structure is created with the originating container as the sole member. The chat is then registered with the chats chat_id -> chat line, and a success result is returned.

To enhance our frontend capabilities, let’s modify the “success” result code to something more informative. For this, we will create a simple “callback” module to store different types of success results.

module callback_t 
{ 
    new_chat = $new_chat.
}

The $symbol_string is simply a plain string literal.

This allows us to flag the transaction’s result for the frontend wrapper.

return transaction::success [
    transaction::action::return_data ($chat -> chat, $type -> callback_t::new_chat)
].

Generating Invites

Excellent, we have successfully created a chat. However, it remains empty. Our next task is to consider methods for adding new members. First and foremost, we must generate an invite. An invite can be defined as follows:

metadef invite_t: ($chat_id -> global_id, $inviter -> global_id).

This structure specifies the chat id it leads to and the inviter, who is responsible for adding a new member to the chat.

module callback_t 
{ 
    new_chat = $new_chat.
    invite_envelope = $invite_envelope.
}

We add a new callback for this transaction as well.

Two utility functions are introduced to streamline the transaction implementation.

fn validate_chat_id (chat_id: global_id) -> chat_t
{
    chat = chats chat_id abort "Chat not found wih chat_id [" + chat_id + "] for actor " + _get_container_id() + "!" when is NIL.
    return chat?.
}

fn validate_chat_not_registered (chat_id: global_id)
{
    abort "Chat already registered with chat_id [" + chat_id + "] for actor " + _get_container_id() + "!" when chats chat_id.
}

These functions demonstrate a useful MUFL feature worth noting: specific error-handling mechanisms. The abort statement in validate_chat_not_registered will terminate the transaction with a given message if the predicate returns anything other than NIL. The latter is returned only when the reduction fails, indicating that the chat registry does not include the provided chat_id.

As for validate_chat_id, the function performs a series of actions:

  • It searches for the chat associated with the provided chat_id within the registry.
  • If no such chat exists (chats chat_id returns NIL), it aborts the transaction with a specified message.
  • Otherwise, it stores it in the chat variable.
  • Finally, it returns this chat, using the chat? syntax to assure the type system that the value is not NIL.

Now the transaction for generating an invite is concise and straightforward.

// ---------------------------------------------
//                 Invitations
// ---------------------------------------------

trn readonly generate_invite chat_id: global_id
{
    validate_chat_id chat_id.
    invite = _write ($chat_id -> chat_id, $inviter -> _get_container_id ()).
    return transaction::success [
        transaction::action::return_data ($invite -> invite, $type -> callback_t::invite_envelope)
    ].
}

The readonly keyword indicates that the transaction will not alter the packet’s state.

1) We first validate the chat ID. 2) An invite structure is created and serialized through the write() primitive. 3) A success result, along with the invite, is returned to the frontend.

The serialized invite can now be converted into a QR code or URL, suitable for sharing through various channels, such as a business card, event poster or others.

Invitation Protocol

Having created the invite, our next step is to design a protocol for joining a chat. A straightforward approach could be as follows: 1) The invitee requests to join the chat. 2) The inviter validates the invite and initiates the following actions: - Requests existing chat members to add the new invitee. - Sends the chat structure to the invitee. 3) All chat members update their chat settings to include the new invitee. 4) The invitee validates the invite and joins the chat.

graph TD
    A[Invitee] -->|Request to Join| B[Inviter]
    B -->|Validate Invite| C[Invite Validated]
    C --> D[Inviter]
    D -->|Ask to Add Invitee| E[Chat Members]
    D -->|Send Chat Structure| F[Invitee]
    E -->|Update Registries| G[Registries Updated]
    F -->|Update Chat Registry| H[Chat Joined]
    
    style G fill:#f9f,stroke:#333,stroke-width:2px;
    style H fill:#9f9,stroke:#333,stroke-width:2px;

First and foremost, a new addition to our utililities collection is needed. Since we are about to deserialize a binary data into an invite structure, we must grab the _read() primitive. This is due to the fact it is dangerous in a sense that we can not guarantee, what exactly are we reading and the message comes from the outside. Thus, a grab( _read ) statement ensures that the programmer does this consciously.
We can immediately assing the grabed value back to the _read name though.

_read = grab( _read ).

Now, the starting transaction of the protocol looks quite straightforward:

trn join_chat invite_link: bin
{
    invite = (_read invite_link) safe invite_t.
    return transaction::success [
        transaction::action::send (invite $inviter) ($name -> "::actor::invite", $targ -> invite $chat_id)
    ].
}

It reads the serialized invite data from the binary string, and then returns a success by calling an invite me transaction for the inviter, handing him a desired chat id. A safe invite_t construct makes a run-time cast of the type in order to detect possible type mismatch.

The inviter is responsile for most of the protocol to come, and the code below reflects that.

trn invite chat_id: global_id
{
    requestor = current_transaction_info::get_external_envelope_or_abort() $from.
    chat = validate_chat_id chat_id.

    send_array is transaction::action::type[] = [].
    sc chat $members -- (member_id->)
    {
        send_array (_count send_array|) -> transaction::action::send member_id
            ($name -> "::actor::add_member", $targ -> ($chat_id -> chat_id, $member_id -> requestor)).
    }

    send_array (_count send_array|) -> transaction::action::send requestor ($name -> "::actor::enter_chat", $targ -> chat).
    return transaction::success send_array.
}

This code is complex, so let’s break it down. The last action of the transaction issues a list of tasks to other members, stored as send_array. This array is returned if the transaction is successful.

The line send_array is transaction::action::type[] = [] specifies the type stored in the array.

The sc (scan) statement in MUFL iterates over data structures, providing options for binding keys and values, nested iteration, filtering, and conditional execution.
It is used as a “range for loop” here, to iterate over the collection of members in the registry. For a detailed explanation, consult the official documentation.

The construct send_array (_count send_array|) -> appends values to send_array. It first gets the array length with index = (_count send_array|), then adds a new element to the array with send_array index ->. A | runtime cast ensures that the index is not nullable.

A transaction call for the requestor is also added before dispatching all commands.

trn add_member _:
($chat_id -> chat_id: global_id, $member_id -> member_id: global_id)
{
    validate_chat_id chat_id.

    chats chat_id $members member_id -> TRUE.
    return transaction::success [].
}

This transaction validates the chat and adds a new member.

// actually joining chat
trn enter_chat chat: chat_t
{
    chat_id = chat $chat_id.
    validate_chat_not_registered chat_id.
    chats chat_id -> chat.

    return transaction::success [
        transaction::action::return_data ($chat -> chat, $type -> callback_t::new_chat)
    ].
}

The invitee verifies that the chat_id is not already known and adds a corresponding chat to his chat registry.

Messaging

The last component is the message logic, updated to fit our new capabilities.

To send a message, follow these steps:

  1. Validate the origin.
  2. Find recipient IDs.
  3. Send the message to each one.
// ---------------------------------------------
//                 Messaging
// ---------------------------------------------

trn send_message message: message_t
{
    // validate the transaction's origin
    current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
    
    // extract recipient ids from the transaction argument
    members = chats (message $chat_id) $members.

    send_array is transaction::action::type[] = [].
    sc members -- (member_id->)
    {
        send_array (_count send_array|) -> transaction::action::send member_id
            ($name -> "::actor::receive_message", $targ -> message).
    }
    return transaction::success send_array.
}

On the receiving end, do two things:

  1. Validate the origin.
  2. Send data back to the front end.
trn receive_message message: message_t
{
    // validate the transaction's origin
    current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
    
    // extract the sender's packet ID from the transaction envelope
    envelope = current_transaction_info::get_external_envelope_or_abort().
    sender_id = envelope $from. // envelope is of 'record' type, use reduction to extract the $from field

    return transaction::success [
        transaction::action::return_data ($message -> message, $sender_id -> sender_id, $type -> callback_t::new_message)
    ].
}

Worth noting, an additional callback type, callback_t::new_message, is required.

Congratulations! The messaging infrastructure is now complete. The final code at this stage is as follows:

application actor loads libraries
    current_transaction_info,
    identity_proof_document,
    attestation_document,
    browser_attestation_document,
    transaction_message_decoder
    uses transactions
{
    hidden
    {
        metadef message_t: ($data -> str, $chat_id -> global_id).
        metadef member_t: global_id.
        metadef chat_t: ($chat_id -> global_id, $chat_name -> str, $members -> member_t(,)).
        metadef invite_t: ($chat_id -> global_id, $inviter -> global_id).
        module callback_t 
        { 
            new_chat = $new_chat.
            invite_envelope = $invite_envelope.
            new_message = $new_message.
        }

        _read = grab( _read ).

        chats is (global_id ->> chat_t) = (,).

        fn validate_chat_id (chat_id: global_id) -> chat_t
        {
            chat = chats chat_id abort "Chat not found wih chat_id [" + chat_id + "] for actor " + _get_container_id() + "!" when is NIL.
            return chat?.
        }
    
        fn validate_chat_not_registered (chat_id: global_id)
        {
            abort "Chat already registered with chat_id [" + chat_id + "] for actor " + _get_container_id() + "!" when chats chat_id.
        }
    }

    // ---------------------------------------------
    //                 Invitations
    // ---------------------------------------------

    trn readonly generate_invite chat_id: global_id
    {
        validate_chat_id chat_id.
        invite = _write ($chat_id -> chat_id, $inviter -> _get_container_id ()).
        return transaction::success [
            transaction::action::return_data ($invite -> invite, $type -> callback_t::invite_envelope)
        ].
    }

    trn join_chat invite_link: bin
    {
        invite = (_read invite_link) safe invite_t.
        return transaction::success [
            transaction::action::send (invite $inviter) ($name -> "::actor::invite", $targ -> invite $chat_id)
        ].
    }

    trn invite chat_id: global_id
    {
        requestor = current_transaction_info::get_external_envelope_or_abort() $from.
        chat = validate_chat_id chat_id.

        send_array is transaction::action::type[] = [].
        sc chat $members -- (member_id->)
        {
            send_array (_count send_array|) -> transaction::action::send member_id
                ($name -> "::actor::add_member", $targ -> ($chat_id -> chat_id, $member_id -> requestor)).
        }

        send_array (_count send_array|) -> transaction::action::send requestor ($name -> "::actor::enter_chat", $targ -> chat).
        return transaction::success send_array.
    }

    trn add_member _:
    ($chat_id -> chat_id: global_id, $member_id -> member_id: global_id)
    {
        validate_chat_id chat_id.

        chats chat_id $members member_id -> TRUE.
        return transaction::success [].
    }

    // actually joining chat
    trn enter_chat chat: chat_t
    {
        chat_id = chat $chat_id.
        validate_chat_not_registered chat_id.
        chats chat_id -> chat.

        return transaction::success [
            transaction::action::return_data ($chat -> chat, $type -> callback_t::new_chat)
        ].
    }

    // ---------------------------------------------
    //                 Messaging
    // ---------------------------------------------

    trn send_message message: message_t
    {
        // validate the transaction's origin
        current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).

        // extract recipient ids from the transaction argument
        members = chats (message $chat_id) $members.

        send_array is transaction::action::type[] = [].
        sc members -- (member_id->)
        {
            send_array (_count send_array|) -> transaction::action::send member_id
                ($name -> "::actor::receive_message", $targ -> message).
        }
        return transaction::success send_array.
    }

    trn receive_message message: message_t
    {
        // validate the transaction's origin
        current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).

        // extract the sender's packet ID from the transaction envelope
        envelope = current_transaction_info::get_external_envelope_or_abort().
        sender_id = envelope $from.

        return transaction::success [
            transaction::action::return_data ($message -> message, $sender_id -> sender_id, $type -> callback_t::new_message)
        ].
    }
}

It is usually a good practice to encapsulate an application’s internal data in a hidden block: this makes them inaccessible to any potential external client.

Adding Encryption

The messenger works but lacks encryption. ADAPT offers built-in encryption algorithms, so we don’t need to build one from scratch. The focus is on developing an encryption protocol for the chat application.

Basic encryption uses public and private keys. Messages can also be signed with another set of keys. After address documents are exchanged, encrypted messages can be sent using transaction::encrypt. An address document holds identity data and two keys: one for signing and one for encryption.

Now, a pretty obvious way to add an end-to-end encryption for our messenger might consist of the following logical steps (backwards):

Messaging

After exchanging address documents, messages can be encrypted with transaction::encrypt.

Getting acquainted

Exchanging address documents in plaintext exposes the system to risks like Man-in-the-middle attacks. To avoid this, another layer of encryption is needed during document exchange.

A secure channel is established by creating a key pair for each invite. The public key is included in the invite, which is signed by the user’s private key, who creates his own unique-for-invite signing keypair. The inviter can then decrypt the message with his own private key, ensuring the message’s integrity and authenticity.

How it looks

The following diagrams show the protocol and process:

sequenceDiagram
    participant Inviter
    participant Invitee
    Inviter->>Inviter: Generate Invite Key Pair
    Inviter->>Inviter: Save Private Key in Invite Registry
    Inviter->>Invitee: Send Encrypted Invite & Public Key<br>+ Encrypted Address Doc
    Invitee->>Invitee: Decrypt w/ Public Key from Invite
    Invitee->>Invitee: Generate Private Signing Key
    Invitee->>Inviter: Send Encrypted Address Doc<br>+ Public Signing Key
    Inviter->>Inviter: Decrypt w/ Private Key & Public Signing Key
    Inviter->>Inviter: Process Invitee's Address Document

This is the part covering the invite generation and the first handshake between the invite owner and the joining person.

graph TD

    G[Inviter] -->|Decrypt w/ Invitee's Public Key| H[Process Invitee's Address Document]
    H -->|Send Encrypted Call to Add New User| I[Chat Members]
    H -->|Send Encrypted Call & Chat Structure| J[Invitee]
    
    I -->|Decrypt w/ Inviter's Public Key| K[Process & Add New User]
    J -->|Decrypt w/ Inviter's Public Key| L[Process Members' Documents & Join Chat]
    
    style K fill:#9f9,stroke:#333,stroke-width:2px;
    style L fill:#9f9,stroke:#333,stroke-width:2px;

This part covers the following processes of the invitee entering the chat, alongside with other members getting to know the new member.

Implementation

To begin the implementation, we first need to load certain standard libraries. These libraries give us access to encryption functionalities and various utilities.

address_document,
address_document_types,
key_utils,
key_storage

We also declare namespaces for ease of reference, thereby avoiding manual typing each time we call a function or a class from these libraries.

using key_utils.
using address_document.

We need to modify and create types to handle user address information as opposed to just their container IDs.

metadef message_t: ($data -> str, $chat_id -> global_id).
metadef member_t: address_document_types::t_address_document.
metadef contacts_t: (global_id ->> member_t).
metadef chat_t: ($chat_id -> global_id, $chat_name -> str, $members -> global_id(,)).
metadef invite_t: ($id -> global_id, $inviter -> address_document_types::t_address_document, $key -> publickey_encrypt).

To use key storage, we must explicitly pass a read primitive since dangerous primitives are only allowed when specified by the programmer.

key_storage::init ($_read -> _read).

Update the container’s registries to store chats, contacts, and invites.

chats     is (global_id ->> chat_t)   = (,).
contacts  is contacts_t = (,).
invites   is (global_id ->> ($secret_key -> secretkey_encrypt, $chat_id -> global_id, $invite -> invite_t)) = (,).

As our transaction implementations become more complex, let’s break down their functionalities into private methods.

fn validate_invite (invite_id: global_id) -> ($secret_key -> secretkey_encrypt, $chat_id -> global_id)
{
    invite = invites invite_id abort "Invite not found with invite_id [" + invite_id + "] for actor " + _get_container_id() + "!" when is NIL.
    return invite?.
}

fn create_invite (chat_id: global_id) -> invite_t
{
    // generate cryptographic keys for the invite message communication
    crypto_scheme = _crypto_default_scheme_id(). 
    keypair = _crypto_construct_encryption_keypair crypto_scheme.

    invite_id = _new_id ("invite to chat: " + chat_id).
    invite is invite_t = ($id -> invite_id, $inviter -> get_my_address_document(), $key -> (keypair $public_key)).
    invites invite_id -> ($invite -> invite, $secret_key -> (keypair $secret_key), $chat_id -> chat_id).

    return invite.
}

The generate_invite transaction is straightforward thanks to our helper functions.

// ---------------------------------------------
//                 Invitations
// ---------------------------------------------

trn generate_invite chat_id: global_id
{
    validate_chat_id chat_id.
    invite = create_invite chat_id.
        
    return transaction::success [
        transaction::action::return_data ($invite -> (_write invite), $type -> callback_t::invite_envelope)
    ].
}

The join_chat transaction is slightly more complex but mainly involves cryptographic key generation and message encryption.

trn join_chat invite_link: bin
{
    invite = (_read invite_link) safe invite_t.
    my_document = get_my_address_document().
    process_address_document (invite $inviter) TRUE. 

    // generate cryptographic keys to encrypt the invite message
    crypto_scheme = _crypto_default_scheme_id(). 
    keypair = _crypto_construct_encryption_keypair crypto_scheme.

    // encrypt the invitation letter
    encryption_key = invite $key.
    invite_id = invite $id.
    encrypted_message = _crypto_encrypt_message (keypair $secret_key) encryption_key (_write my_document).

    return transaction::success [
        transaction::action::send (invite $inviter $identity $container_id)
            ($name -> "::actor::invite", $targ ->
                ($invite_id -> invite_id, $docs -> encrypted_message, $invitee_key -> (keypair $public_key)))
    ].
}

The following transaction, named invite, contains the core logic for encryption and decryption.

trn invite _:(
    $invite_id -> invite_id: global_id,
    $docs -> member_document_encrypted: crypto_message,
    $invitee_key -> signing_key: publickey_encrypt
)
{
    // get the invite cryptographic keys
    validate_invite invite_id => ($secret_key -> decryption_key, $chat_id -> chat_id).

    // decrypt the members address document
    decrypted_docs = _crypto_decrypt_message decryption_key signing_key member_document_encrypted.
    member = (_read decrypted_docs) safe member_t.

    // check that the user sending the request is the one whose document is encrypted in the request
    requestor = current_transaction_info::get_external_envelope_or_abort() $from.
    abort "The user sending the request is not the one whose document is encrypted in the request!"
        when requestor != member $identity $container_id.

    // request other chat members to add the user to their member lists
    chat = validate_chat_id chat_id.
    contacts_info is contacts_t = (,).
    send_array is transaction::action::type[] = [].
    sc chat $members -- (member_id -> )
    {
        // find contact info for a given member
        contacts_info member_id -> (contacts member_id).
        
        // encrypt the message
        encrypted_trn = transaction::encrypt (
            $cid           -> member_id,
            $trn           -> ($name -> "::actor::add_member", $targ -> ($chat_id -> chat_id, $member -> member)),
            $isemsignature -> TRUE
        ).
        send_array (_count send_array|) -> transaction::action::send member_id encrypted_trn.
    }

    process_address_document member TRUE.

    // add the requestor to the chat
    encrypted_trn = transaction::encrypt (
        $cid -> requestor,
        $trn -> ($name -> "::actor::enter_chat", $targ -> ($chat -> chat, $contacts_info -> contacts_info)),
        $isemsignature -> TRUE
    ).
    send_array (_count send_array|) -> transaction::action::send requestor encrypted_trn.

    return transaction::success send_array.
}

A validate_invite invite_id => ($secret_key -> decryption_key, $chat_id -> chat_id). feature used above is a pattern binding, that unpacks an expression on the left hand side into a structure on the right hand side, binding the elements to the corresponding new names.

The last steps in the authentication process remain mostly unchanged.

trn add_member _:
($chat_id -> chat_id: global_id, $member -> member: member_t)
{
    current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
    abort "Transaction is expected to be encrypted!" when not current_transaction_info::is_encrypted().
    abort "Transaction is expected to be singed!" when not current_transaction_info::is_signed().

    validate_chat_id chat_id.

    // register the new member
    process_address_document member TRUE.
    member_id = member $identity $container_id.
    chats chat_id $members member_id -> TRUE.
    contacts member_id -> member.
    return transaction::success [].
}

// actually joining chat
trn enter_chat _:
($chat -> chat: chat_t, $contacts_info -> contacts_info: contacts_t)
{
    current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
    abort "Transaction is expected to be encrypted!" when not current_transaction_info::is_encrypted().
    abort "Transaction is expected to be singed!" when not current_transaction_info::is_signed().
    
    chat_id = chat $chat_id.
    validate_chat_not_registered chat_id.

    // register other members' documents
    sc chat $members -- (member_id -> )
    {
        contact_info = contacts_info member_id.
        contacts member_id -> contact_info.
        process_address_document contact_info? TRUE.
    }

    // add myself to the chat member registry
    chat $members _get_container_id() -> TRUE.
    chats chat_id -> chat.
    return transaction::success [
        transaction::action::return_data ($chat -> chat, $type -> callback_t::new_chat)
    ].
}

The messaging part also doesn’t differ significantly, with the only exception being the use of transaction::encrypt for encrypted communication.

// ---------------------------------------------
//                 Messaging
// ---------------------------------------------

trn send_message message: message_t
{
    // validate the transaction's origin
    current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
    
    // extract recipient ids from the transaction argument
    members = chats (message $chat_id) $members.

    send_array is transaction::action::type[] = [].
    sc members -- (member_id -> )
    {
        // encrypt the message
        encrypted_trn = transaction::encrypt (
            $cid           -> member_id,
            $trn           -> ($name -> "::actor::receive_message", $targ -> message),
            $isemsignature -> TRUE
        ).
        send_array (_count send_array|) -> transaction::action::send member_id encrypted_trn.
    }
        
    return transaction::success send_array.
}
    
trn receive_message message: message_t
{
    // validate the transaction's origin
    current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
    abort "Transaction is expected to be encrypted!" when not current_transaction_info::is_encrypted().
    abort "Transaction is expected to be singed!" when not current_transaction_info::is_signed().
    
    // extract the sender's packet ID from the transaction envelope
    envelope = current_transaction_info::get_external_envelope_or_abort().
    sender_id = envelope $from.
        
    return transaction::success [
        transaction::action::return_data (
            $message -> message,
            $sender_id -> sender_id,
            $type -> callback_t::new_message
        )
    ].
}

For a complete view, you can find the final implementation on GitHub. Future enhancements could include adding message timestamps and usernames to make the UI more interactive and user-friendly.

III. Front-End Application

After a deep dive into MUFL, which implements the logic of the messenger, it is time for us to build the user interface for the application. We won’t be breaking down the exact steps for styling with CSS, but will rather take a closer look at the MUFL-TypeScript bindings which will serve us as an example of a practical ADAPT application embedding into the web-environment.

In short, the MUFL-TypeScript bindings are needed in order to display information to the user of the messenger when something happens (for example, a new message is received); and to cause something to happen in the ADAPT-based data mesh in response to user actions (for example, when the user requests a message to be sent).

A full code listing can be accesses at official Adapt Messenger repository.

Libraries and Modules

First, let’s start by understanding what we import:

import { adapt_js_api, adapt_js_api_utils, adapt_wrapper_browser, adapt_wrappers } from "adapt_utilities"
import { copyToClipboard } from './utils';
  • adapt_utilities: This is the main module where all the ADAPT functionalities are imported from.
  • copyToClipboard: This is a utility function that we use to copy the invite to the clipboard.

The AdaptMessengerAPI Class

The AdaptMessengerAPI class is the core of our messenger front-end:

export class AdaptMessengerAPI {

    private __on_chat_created_cb?: (chat_id: string, chat_name: string) => void;
    private __on_message_received_cb?: (chat_id: string, message: string, timestamp: string, from_id: string, from_name: string, incoming: boolean) => void;
    private __on_set_user_name_cb?: (user_name: string) => void;

Here, we define three optional callback functions to handle chat creation, message reception, and setting the user name. These are the reason we needed to define the callback messages in MUFL.

Constructor

In the constructor, we initialize the class with an AdaptPacketWrapper:

constructor(public packet: adapt_wrappers.AdaptPacketWrapper) { /*...*/}

We also define a function packet.on_return_data to process returned data from the back-end:

packet.on_return_data = (data: adapt_js_api.AdaptValue) => {
    // Handling data types like 'new_chat', 'invite_envelope', 'new_message', etc.
}

Message Sending and Chat Functions

send_message, create_chat, connect_to_chat, and generate_invite are straightforward methods to handle common chat functionalities. They convert JavaScript objects to ADAPT values before sending them off:

send_message = (message: string, chat_id: string) => {
    // Implementation
}

create_chat = (chat_name: string) => {
    // Implementation
}

Setters for Callbacks

You can register callback functions for when a chat is created, a message is received, or a user name is set.

set on_chat_created(on_chat_created_cb: (chat_id: string, chat_name: string) => void) {
    this.__on_chat_created_cb = on_chat_created_cb;
}

// Similarly for other callbacks

Initializing the Application

Finally, we initialize the application in the initialize function, which creates a packet wrapper and invokes the on_initialized callback:

export const initialize = async (broker_address: string, code_id: string, seed_phrase: string, on_initialized: (adapt_messenger_api: AdaptMessengerAPI) => void): Promise<void> => {
    // Implementation
}

Front-end is ready to go

With that, you should now have a good understanding of how the front-end of our ADAPT Messenger app works. From setting up the ADAPT environment to sending messages and handling chats, the TypeScript code uses ADAPT utility methods to handle the core functionalities. With this architecture, extending the messenger to incorporate additional features or tweaking existing ones should be a breeze.

The CSS and HTML files used to build the front-end are straightforward enough and are beyond the scope of this tutorial. They can be found in the project’s repository for those interested.

IV. Summary

In this tutorial we have constructed a messenger application using under 300 lines of code. While this only includes business logic of the messenger (placing scalability questions out of scope), it is still an impressive feat for an application that provides full end-to-end encryption of messages between N users.

The application operates on a decentralized framework (except the message broker), a feature that’s becoming increasingly important in today’s digital age. Throughout this tutorial, the focus has been on a deep understanding of the ADAPT framework, avoiding unnecessary complexities.

Looking ahead, there is potential to expand on this foundation. Ideas for subsequent sections include diving into packet state saving, which would make possible features like retaining chat history. For a hands-on experience of what we’ve discussed, a demo of the messenger is available.