DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Blockchatting: A Peer-to-Peer Messaging dApp
Erhan Tezcan
Erhan Tezcan

Posted on

Blockchatting: A Peer-to-Peer Messaging dApp

Greetings! In this post, I will describe how I have built a decentralized chatting application that runs on the blockchain.

This application is smart-contract based, rather than a more lower-level protocol such as Ethereum Whisper, Waku, TransferChain, or Status. As such, it is a much non-optimal application and is mostly done for educational purposes.

Messages are end-to-end encrypted with a secret key unique to each peer. However, each message is a transaction on the blockchain; which is funny enough that I used to call this app "Chat for Rich People" since for each message you must pay a transaction fee. Not an issue for the testnets though.

We will have three sections in this post:

  • Methodology
  • Solidity Contract
  • NextJS Frontend

Let's begin!

Methodology

We want a chatting application where users login with their wallets, talk to each other with end-to-end encryption, with no central backend server whatsoever. Furthermore, we want no further ID than the user address.

For the end-to-end encryption, we will have two phases:

  • User Initialization
  • Chat Initialization

User Initialization

When a user comes to the application for the first time, we ask the user for an initialization. The user will create a random 32-byte seed phrase, which will be used to generate a private & public key-pair. We denote this as sk_chat and pk_chat respectively.

pk_chat can be derived from the private key. So, if we could store the sk_chat with encryption, then we could retrieve it later to derive our keys again. However, what other key would we use to encrypt that sk_chat?

We have two deprecated MetaMask RPC calls for this purpose:

It is important to note again that these are deprecated, and might be removed in future. Furthermore, making these RPC calls require user interaction via the wallet, which is not good for UX. Anyways, we can encrypt sk_chat with our own EOA public key, store it in the contract, and then decrypt it later with our EOA private key! This needs to be done only once during the application, so it is an acceptable UX cost.

user initialization

Upon completion, the user stores their encrypted sk_chat, and plain pk_chat in the contract! Oh, and we also take a small bit of entry fee for all this πŸ’ΈπŸ’Έ.

Chat Initialization

Now suppose that two users (say Alice and Bob) have completed the user initialization steps, and they would like to talk to each other. Note that to do this, one needs to know another's address. Assuming they do know their addresses, they want to ensure that only they can read their messages!

Symmetric encryption suits perfectly here: it is a lot more efficient than using asymmetric encryption (with sk_chat and pk_chat from before). Here is how: the first time either Alice or Bob comes to the chat screen, they must initialize the chatting session; suppose Alice was there the first. She generates a random 32-byte symmetric key, and encrypt this key with both her and Bob's public key that was stored from the user initialization phase. This way, there is a secret key stored in the contract that only Alice and Bob can read, and this key is unique to these two only!

chat initialization

End-to-End Encryption

Now that two users have agreed upon a secret symmetric key, which only they can access via their private keys, they can start using this key to encrypt & decrypt their messages.

end-to-end encryption

Solidity Smart-Contract

The key idea is that each message is stored as an event log:

event MessageSent(
  address indexed _from, // sender address
  address indexed _to,   // recipient address
  string _message,       // encrypted message
  uint256 _time          // UNIX timestamp
);

function sendMessage(
  string calldata ciphertext,
  address to,
  uint256 time
) external {
  if (!isChatInitialized(msg.sender, to)) {
    revert BlockchattingError(ErrChatNotInitialized);
  } 
  emit MessageSent(msg.sender, to, ciphertext, time);
}
Enter fullscreen mode Exit fullscreen mode

and a user retrieves their messages by querying these events. There are also events emitted from user initializations and chat initializations:

struct UserInitialization {
  bytes encryptedUserSecret;
  bool publicKeyPrefix;
  bytes32 publicKeyX;
}

event UserInitialized(address indexed user);

function initializeUser(
  bytes calldata encryptedUserSecret,
  bool publicKeyPrefix,
  bytes32 publicKeyX
) external payable {
  if (isUserInitialized(msg.sender)) {
    revert BlockchattingError(ErrUserAlreadyInitialized);
  }
  if (msg.value != entryFee) {
    revert BlockchattingError(ErrIncorrectEntryFee);
  } 
  userInitializations[msg.sender] = UserInitialization(encryptedUserSecret, publicKeyPrefix, publicKeyX);
  emit UserInitialized(msg.sender);
}
Enter fullscreen mode Exit fullscreen mode
event ChatInitialized(address indexed initializer, address indexed peer);

function initializeChat(
  bytes calldata yourEncryptedChatSecret,
  bytes calldata peerEncryptedChatSecret,
  address peer
) external {
  if (!isUserInitialized(msg.sender)) {
    revert BlockchattingError(ErrUserNotInitialized);
  }
  if (!isUserInitialized(peer)) {
    revert BlockchattingError(ErrPeerNotInitialized);
  } 
  chatInitializations[msg.sender][peer] = yourEncryptedChatSecret;
  chatInitializations[peer][msg.sender] = peerEncryptedChatSecret;
  emit ChatInitialized(msg.sender, peer);
}
Enter fullscreen mode Exit fullscreen mode

You might also notice the usage of custom error codes. These are much more optimized than using require with custom messages stored as strings; instead, we do as follows:

uint8 constant ErrUserAlreadyInitialized = 1;
uint8 constant ErrChatNotInitialized = 2;
uint8 constant ErrUserNotInitialized = 3;
uint8 constant ErrPeerNotInitialized = 4; 
uint8 constant ErrIncorrectEntryFee = 5; 
error BlockchattingError(uint8 code);
Enter fullscreen mode Exit fullscreen mode

The client can read error code meanings from here, which are named following Go language error variable naming conventions.

The contract itself is quite simple as you can see, it is on the client-side that much of the heavy lifting goes on. You can check the source code of the contract here. It also has tests implemented with Hardhat + TypeScript.

NextJS Frontend

Admittedly, this application is a single-page app and NextJS could be a bit overkill for this. I used it regardless, may be due to change in the future!

For the wallet connection, we use WAGMI. The chat contract is connected within a React context, so that all components can interact with it. Contract interactions are also type-checked via TypeChain. These types are created on the contract development phase, but you can easily copy & paste them to your types directory at frontend, or wherever you store them.

For UI/UX purposes, we also map each address to some randomly generated nickname and avatar, making things a lot more readable! A similar approach is used in Status messenger. I have also used MantineUI as my component library, which I highly recommend.

The application basically has 3 phases:

  • UserInitialization is checked, and is requested if it is not present.
  • The user then sees previous chat session by querying ChatInitialization events with their address as a parameter. They can also create a new session by entering the address of who they would like to talk to.
  • When they begin a chatting session with some peer, they can query their previous messages via MessageSent events.

You can see a live demo at https://blockchatting.vercel.app/ which uses GΓΆerli testnet, and feel free to check out the code at https://github.com/erhant/blockchatting!

Happy coding :)

Top comments (0)

πŸ— We built a 100% open source community software called Forem.

You can contribute to the codebase or host your own.