Skip to main content

Chroma chat

In this section, we write the Rell backend for a public chat dapp.

Requirements

The requirements we set are the following:

  • Users authenticate with a wallet like Metamask by signing a message.
  • Users get identified by their public key from the signature.
  • The signed message forms a session token and includes a timestamp that sets a session length of 20 minutes.
  • A fixed amount of tokens are automatically assigned to new users (for example, 1,000,000).
  • Channels are streams of messages belonging to the same topic, specified by the channel's name (for example, showerthoughts, where you can share thoughts you had in the shower).
  • Registered users can create channels.
  • When a user creates a new channel, they become the administrator of that channel. The administrator can add any existing users to the channel. This operation costs one token.

Setting up the local environment

These instructions assume you have Node.js and the Chromia CLI installed on your machine.

Begin by cloning the chat-sample repository from Bitbucket:

git clone git@bitbucket.org:chromawallet/chat-sample.git

Environment variables

The environment variables for this project are specified in chroma-chat-client/.env.

WalletConnect

This example uses Web3Modal from WalletConnect to handle wallet connections. Every project using WalletConnect SDKs (including Web3Modal) needs to obtain projectId from WalletConnect Cloud. It's free and only takes a few minutes.

Once you have obtained a projectId, you need to specify it in chroma-chat-client/.env as:

REACT_APP_WALLET_CONNECT_PROJECT_ID=<your projectId goes here>

Blockchain RID

The blockchain RID changes whenever you make changes to the Rell code. The example repository comes pre-packaged with the blockchain RID of the Rell code in chroma-chat-client/.env, but if you make any changes to the Rell code, you can update it as:

REACT_APP_BLOCKCHAIN_RID=<your blockchainRID goes here>

Run the dapp server

  1. Open a terminal window and navigate to chat-sample/rell:
cd chat-sample/rell
  1. Start a node running the dapp backend:
chr start

Run the dapp client

  1. Open a new terminal window and navigate to chat-sample/chroma-chat-client:
cd chat-sample/chroma-chat-client
  1. Install the necessary dependencies:
npm install
  1. Start the client:
npm run start

Congratulations, you should now have a local node with your dapp backend and a client running.

Authentication

This example uses Ethereum Virtual Machine (EVM) compatible wallets (such as MetaMask) for authentication.

Disposable key pair

Chromia doesn't support signatures by EVM wallets on the transaction. Thus, the client in this example generates a disposable Chromia key pair to sign the transaction. The client stores the private key of the disposable key pair in the browser's session storage to maintain the session. After each session, the client discards the disposable key pair. It's important to note that this disposable key is supplementary and considered part of the session token. Users shouldn't store their private keys in their wallets like this.

Generate session token

The client creates a session token when a user signs in to the dapp. The session token is a message that the users sign with their wallet. The Rell backend can then extract the user's public key from the signature and use it for authorization. The message contains a prompt to inform the user what the message is for, the public key of the disposable Chromia key pair that's going to sign the transaction, and a Unix timestamp from when the user signed in. The Rell backend uses this timestamp to enforce the expiration of sessions.

The process to create a session token in the client is as such:

  1. Request the users to connect their wallet
  2. Generate a message with the prompt, disposable public key, and current Unix timestamp
  3. Request the user's signature on the message
  4. If signed, create a sessionToken from the message and the signed message

Each operation includes the session token as an argument.

const message = {
prompt:
"Sign this message to prove that you have access to this wallet to sign in. This operation is off-chain and free of cost.",
disposable_pubkey: generatedKeyPair.pubKey.toString("hex"),
timestamp: Date.now(),
};

// ...

const sessionToken = {
message,
signedMessage,
};

This project uses WalletConnect for wallet integration and wagmi for signing messages. We refer to their documentation for details on the integration or to this project's code repository.

Verify session token

The Rell backend verifies that the public key disposable_pubkey found in the message is the signer of the transaction using op_context.is_signer(). This is necessary to prevent adversaries from stealing users' session tokens since the arguments of transactions to the blockchain are public.

Furthermore, the Rell backend extracts the public key from the signature in the session token. This process proves that the user that posted the operation owns the public key (that is, has the private key corresponding to the public key). A timestamp is available in the session token to mitigate the risk of replay attacks by enforcing the session's expiration. The backend checks that the timestamp is less than 20 minutes old. Furthermore, the backend verifies that the timestamp isn't in the future as safety to prevent users from creating longer sessions.

The time in Rell is a bit different because it's based on the build time of the previous block. If the time in the backend is slightly behind, the recently created session tokens might be wrongfully rejected. Hence, a fixed offset gets added to allow session tokens from somewhat in the future.

The following are the types of session tokens:

struct session_token {
message: session_message;
signed_message: signature;
}

struct session_message {
prompt: text;
disposable_pubkey: text;
timestamp;
}

struct signature {
r: text;
s: text;
v: integer;
}

The process to verify a session token is as follows:

  1. Include a session token as an argument in operations
  2. Pass the token to validate_session(token) from the authentication module
  3. validate_session extracts the public key from the signature and validates the timestamp
  4. If the token is valid, validate_session returns the public key for authorization

The following are the key parts of validating a session token in Rell. validate_session uses verify_message to extract the signer's public key from a signature and is_session_alive to validate the timestamp of the token:

function verify_message(message: text, sig: signature): pubkey {
val message_hash = keccak256(("\u0019Ethereum Signed Message:\n" + message.size().to_text() + message).to_bytes());
val ecrec_result = eth_ecrecover(
byte_array(sig.r.sub(2)),
byte_array(sig.s.sub(2)),
sig.v - 27,
message_hash
);
val recovered_address = keccak256(ecrec_result).sub(12);
return recovered_address;
}

function is_session_alive(token: session_token): boolean {
val current_time = estimate_current_time();
val signed_time = token.message.timestamp;
if (signed_time > current_time + TIME_OFFSET) return false;
if (signed_time < current_time - SESSION_LENGTH) return false;
return true;
}

function validate_session(token: session_token): pubkey? {
val message_text = session_msg_to_json(token.message);
val recovered_address = verify_message(message=message_text, sig=token.signed_message);
if (not is_session_alive(token)) return null;
return recovered_address;
}

validate_session returns null if the session token isn't valid, which means that you can use require(validate_session(token)) in operation to throw an error if the token is invalid:

operation some_operation (token: session_token, ...) {
val pubkey = validate_session(token);
require(pubkey);
// ...
}

Authentication overview

The following schema provides an overview of the authentication.

Entity definition

The structure is as follows:

entity user {
key pubkey;
key username: text;
}

entity channel {
key name;
admin: user;
}

entity channel_member {
key channel, member: user;
}

entity message {
key channel, timestamp;
index posted_by: user;
text;
}

entity balance {
key user;
mutable amount: integer;
}

User

The public key or username can identify a user. Both the public key and username are vital attributes and are, therefore, unique.

Currently, in this example, a user's username is their public key upon registration.

Channel

You can identify the channels by their name (ideally reflecting the channel's topic) and the user who created it. Note that the two channels can't have the same name (key), and a user can be the administrator of multiple channels.

Message

A message consists of a text, a reference of the user who sent it, the channel where it's posted, and a timestamp. The key channel, timestamp, means that you can send only one message in a channel at a given timestamp. However, you can send multiple messages in different channels at the same timestamp.

Balance

Users can spend tokens on operations, such as sending messages. A user gets several tokens upon registration. For this reason, the field is mutable.

Operations

Operations are necessary to modify data in the database.

Transfer tokens (function)

For convenience, you can create a function to deduct tokens from a user's balance.

function deduct_balance(user, amount:integer) {
update balance@{user} (amount -= amount);
}

Register a new user

A new user gets created and assigned a balance of 1,000,000 tokens.

operation register (token: session_token, username: text) {
val pubkey = validate_session(token);
require(pubkey);
val new_user = create user (pubkey, username);
create balance (new_user, 1000000);
}

Here we:

  • Identify the user's public key with validate_session(token).
  • Require that a public key is available; otherwise, throw an error.
  • Create a new user and transfer the specified amount of tokens.

If the conditions fail at any point in the operation (for example, when the username already exists), the whole process gets rolled back, and the transaction gets rejected.

Create a new channel

Registered users can create new channels. Given the public key from the session token as well as the name of the channel, we verify the user registration, deduct a fee, create the channel, and finally, add that user as a member. The cost to create a channel is 100 tokens. When you create the channel, the user gets added as an administrator, meaning they can add users to the channel.

operation create_channel (token: session_token, channel_name: name) {
val pubkey = validate_session(token);
require(pubkey);
val admin_user = user@{pubkey};
deduct_balance(admin_user, 100);
val channel = create channel (admin_user, channel_name);
create channel_member (channel, admin_user);
}

Add a user to the channel

The channel administrator (the one who created the channel) can add other users after having paid a fee of one token.

Once again, we get the user's public key from the session token. Then, we deduct one token from the channel administrator and add a new user to the channel via channel_member.

operation add_channel_member (token: session_token, channel_name: name, new_member_username: text) {
val pubkey = validate_session(token);
require(pubkey);
val admin_user = user@{pubkey};
deduct_balance(admin_user, 1);
val channel = channel@{channel_name, .admin==user@{pubkey}};
val new_user = user@{.username == new_member_username};
create channel_member (channel, member=new_user);
}

Post a new message

People in a channel love to share their opinions. They can do so with the post_message operation. The signer (val pubkey = validate_session(token)) can post a message on the channel (val channel = channel@{channel_name};) if they're a member of the channel (require( channel_member@?{channel, member} )).

After paying one token fee, we add the new message to the channel.

operation post_message (token: session_token, channel_name: name, message: text) {
val pubkey = validate_session(token);
require(pubkey);
val channel = channel@{channel_name};
val member = user@{pubkey};
require( channel_member@?{channel, member} );
deduct_balance(member, 1);
create message (channel, member, text=message, op_context.last_block_time);
}

Queries

It's helpful to write data into a database in a distributed fashion, although writing would only be meaningful with the ability to read.

Get a user's channels

Getting the channels a user is a member of is simple, selecting from channel_member with the given user's public key.

query get_channels(pubkey) : list<(name:text, admin: text)> {
return channel_member@*{.member == user@{pubkey}} (name = .channel.name, admin = .channel.admin.username);
}

Get a user's balance

Similarly to how we query a user's channels, we can get a user's balance.

query get_balance(pubkey) {
return balance@{ user@{ pubkey } }.amount;
}

Get messages from a channel

This query retrieves messages sent in one channel sorted from the oldest to the newest.

query get_last_messages(channel_name: name):list<(text:text, poster:text, timestamp:timestamp)> {
return message@*{ channel@{channel_name} }
( .text, poster=.posted_by.username, @sort .timestamp );
}

Client-side

This section covers parts of a JavaScript-based client for the dapp. Here the discussion is only about the aspects regarding communication with the dapp backend. The complete code of the client is available here.

The client uses the postchain-client npm package, which you can find here.

The postchain-client sets up a GTX client. The GTX client sends queries and operations to the node where the Rell backend is running. To create a GTX client, we need to declare the REST server's address (run by the node, the default port is 7740), the blockchain RID of the blockchain, and the number of sockets (5).

We then get an instance of GTX Client via gtxClient.createClient and give the REST object and blockchain RID as input. The last parameter is an empty list of operations.

import * as pcl from "postchain-client";

const endpointPool = ["http://localhost:7740"];
const blockchainRID =
"0B7F93149FB4B1D634D6F14CCB8058F917BBECB7693E9A582C2E74FEBF364501";
const rest = pcl.restClient.createRestClient(endpointPool, blockchainRID, 5);
const gtx = pcl.gtxClient.createClient(
rest,
Buffer.from(blockchainRID, "hex"),
[]
);

Remember to update the blockchain RID if you change the Rell backend.

Create and send transactions

In chroma-chat-client/src/api.js, functions get exported for each operation and query in the backend that's used in the client. You should have a look at postMessage. This function takes the channel name and message as arguments. The dapp uses wallets for authentication, so a "dummy" key pair signs the transactions for operations. A "dummy" keypair gets exported in chroma-chat-client/src/blockchain.js and retrieved here by blockchain.getDummyKeyPair().

export const postMessage = (channelName, message) => {
const dummyKeyPair = blockchain.getDummyKeyPair();
const sessionTokenGtv = auth.getCurrentTokenGtv();
const rq = blockchain.getGtx().newTransaction([dummyKeyPair.pubKey]);
rq.addOperation("nop", crypto.randomBytes(32));
rq.addOperation("post_message", sessionTokenGtv, channelName, message);
rq.sign(dummyKeyPair.privKey, dummyKeyPair.pubKey);
return rq.postAndWaitConfirmation();
};

The "dummy" keypair exported from blockchain.js looks as such:

const dummyPubKey = Buffer.from(
"031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
"hex"
);
const dummyPrivKey = Buffer.from(
"0101010101010101010101010101010101010101010101010101010101010101",
"hex"
);
const dummyKeyPair = { pubKey: dummyPubKey, privKey: dummyPrivKey };

In the postMessage function, we declare a new transaction and provide the public key corresponding to the private key, which signs the transaction so a node can verify the integrity of the transaction.

The blockchain doesn't accept transactions whose hash equals an existing transaction to protect the system from replay attacks. If at one point the user writes hello, the transaction is something like rq.addOperation("post_message", the_channel, user_pub, "hello"). If the user writes "hello" a second time, the transaction is identical and therefore rejected. It means a user can't write the same message twice in a channel. To solve this problem, we add a nop operation with some random bytes via rq.addOperation("nop", crypto.randomBytes(32)). This operation creates a different transaction hash.

It's essential to remember this limitation on transactions. If your transaction gets rejected with no apparent reason, chances are that it's missing a "nop" operation.

We then add the operation called post_message and pass the session token, channel name, and message as an input argument. We then sign the transaction with the "dummy" private key (we also specify the public key here to correlate which private key refers to which public key in case of multiple signatures).

Finally, we send the transaction to the node via postAndWaitConfirmation, which returns a promise and resolves once the transaction is confirmed.

Other operations

Another two functions are available for the create_channel and add_channel_member operations. These work similarly to the preceding postMessage function.

export const createChannel = (channelName) => {
const dummyKeyPair = blockchain.getDummyKeyPair();
const sessionTokenGtv = auth.getCurrentTokenGtv();
const rq = blockchain.getGtx().newTransaction([dummyKeyPair.pubKey]);
rq.addOperation("create_channel", sessionTokenGtv, channelName);
rq.sign(dummyKeyPair.privKey, dummyKeyPair.pubKey);
return rq.postAndWaitConfirmation();
};

export const inviteUserToChat = (channel, username) => {
const dummyKeyPair = blockchain.getDummyKeyPair();
const sessionTokenGtv = auth.getCurrentTokenGtv();
const rq = blockchain.getGtx().newTransaction([dummyKeyPair.pubKey]);
rq.addOperation("add_channel_member", sessionTokenGtv, channel, username);
rq.sign(dummyKeyPair.privKey, dummyKeyPair.pubKey);
return rq.postAndWaitConfirmation();
};

Querying the blockchain

Previously, we wrote the queries on the blockchain side. Now, we need to query from the dapp client side. Same as for the transactions, we use the previously mentioned postchain-client package.

We take a closer look at the get_balance query. For reference, here is the query in Rell:

query get_balance(pubkey) {
return balance@{ user@{ pubkey } }.amount;
}

The following is the JavaScript function on the client side that calls the get_balance query.

export const getBalance = () => {
const { pubKey } = auth.getCurrentUser();
return blockchain.getGtx().query({
type: "get_balance",
pubkey: pubKey.toString("hex"),
});
};

The user's public key is first retrieved from chroma-chat-client/src/blockchain/auth.js through auth.getCurrentUser(). The rest is available in the gtx.query: the first argument is the query name in the Rell module, and the second argument is the name of the expected attribute in the query itself wrapped in an object. The object's name is specified in the module, and the value is the value we want to send.

Other queries

Another two functions are available for the get_channels and get_last_messages queries. These work similarly to the preceding getBalance function.

export const getChannels = () => {
const { pubKey } = auth.getCurrentUser();
return blockchain.getGtx().query({
type: "get_channels",
pubkey: pubKey.toString("hex"),
});
};

export const getMessages = (channelName) => {
return blockchain.getGtx().query({
type: "get_last_messages",
channel_name: channelName,
});
};

Conclusion

This example project consists of a Rell backend for the public chat and a JavaScript client to communicate with it. Note that this is just a simple example project and might contain bugs and implementations that aren't best practices.

We encourage you to extend this sample in any way you like.