Ori Pomerantz Ori has been securing IT, and teaching others to do the same, since the start of his career in 1995. In addition to raising seven kids with his wife, he works @optimismPBC and writes about Ethereum on the side.

Design and build a blockchain social media platform

10 min read 2842

Ethereum Logo With a Bird

Centralized social media, such as Facebook and Twitter, does not fit all use cases. For example, centralized social media platforms are tied to a central authority, the provider. That provider has the ability to remove or hide user posts.

In addition, because centralized social media is a mutable record, it could potentially be changed. This has all sorts of ramifications. For example, it could be difficult for a user to prove when they posted something. This could have implications for several business cases, such as patent litigation or the evaluation of experts based on their predictions.

Mutability also brings up the risk of nefarious actions in which a user’s posts could be modified, or in which content could be posted under another user’s profile.

Here’s where blockchain could be a game changer. A blockchain is a ledger that is practically immutable. It provides a permanent record that is not feasible to falsify or erase, making it ideal for certain social networking use cases.

In this article, we’ll design and build a blockchain social media platform, called Chirper, that will run on top of Ethereum.

Contents

Designing the social media platform

To design a decentralized blockchain social media platform, we’ll need to consider how the information will be stored and how it will be accessed.

Information storage

The only way to provide information into the blockchain is to place it as part of a transaction. Once the transaction is added to a block, it becomes part of the blockchain’s permanent record. This does not mean that the stored information is easy to search.

Once data is in the blockchain, there are additional actions we can take with it, but everything we do that writes on the blockchain costs money. For this reason, it’s best to restrict ourselves to actions that are absolutely necessary to enable functionality.

Indexing

As of this writing, there are over 15 million blocks in the Ethereum blockchain. It would not be feasible to download all of them to look for messages. So instead, we’ll keep an index that consists of a mapping between addresses and arrays.

For every address, the array will contain the serial numbers of the blocks in which that address was posted. After a user application receives this list, it can query an Ethereum endpoint to retrieve the blocks, and search them for the messages.



Specifying the data and control flows

Next, we need to determine how a user will post messages and how they will find messages.

Posting messages

Posting a message requires two actions:

  • Writing the message as part of a transaction
  • Adding the message’s block into the index

This is the flow we’ll use to implement message posting:

  1. A user application sends a transaction to an on-chain contract with the message; the message is automatically written to the Ethereum permanent record
  2. The contract verifies that it was called directly by a transaction, rather than by another smart contract. This step is necessary because the algorithm we use to find messages only works if it is called directly (since it looks at transactions)
  3. The contract identifies the sender and the current block number
  4. The contract appends the block number to the sender’s message blocks array. If the sender does not yet have a message blocks array it does not matter, because it is treated as a zero-length array

Finding messages

This is the flow that we’ll use to enable a user to find, read, and interpret messages:

  1. A user application calls an on-chain contract with the address associated with the requested messages. Because this is a view function (a read-only function), it only gets executed on a single node and does not cost any gas
  2. The user application reads the blocks in the list, including their transactions, and filters them to find relevant transactions that fulfill these conditions:
    • Has the sender we are looking for as a source
    • Has the Chirper contract as the destination
    • (Optional) Has the correct function signature. This is only necessary if we have multiple functions in Chirper that accept transactions; if we just have one function for a post then we don’t need to check the function signature
  3. The user application converts the transaction call data into strings to get back the posts the user posted
  4. The user application converts the block numbers to timestamps
  5. The user application displays the posts (or a subset of them) to the user

Building the social media platform application

The application source code used in this article is available on GitHub. It contains two files, a Solidity contract and JavaScript code that includes the API (how to use the contract) and tests (how to verify the contract works correctly).

Writing the Solidity contract

We’ll use a minimal contract implements the social network on the blockchain.

The initial lines of code specify the license and the version of the Solidity programming language that is used to compile the contract. Solidity is still developing quickly, and other versions, either earlier (v0.7.x) or later (v0.9.x) may not compile the contract correctly.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

Next, we define the contract:


More great articles from LogRocket:


contract Chirper {

Then, we include the data structure that holds the blocks list for every address that posts to this contract.

    // The blocks in which an address posted a message
    mapping (address => uint[]) MessageBlocks;

Here’s the function that off-chain applications call to post information.

    function post(string calldata _message) external {
        // We don't need to do anything with the message, we just need it here
        // so it becomes part of the transaction.

We don’t really need _message, it is just a parameter so that the off-chain code will put it in the transaction. However, if we don’t do anything with it the compiler will complain. So we’ll include it to avoid that behavior!

        (_message);

Right now, it’s only possible to look in the transaction to see the message if the message is in the transaction itself. If the Chirper contract is called internally by another contract, that does not happen; therefore, we do not support that action.

        require(msg.sender == tx.origin, "Only works when called directly");

N.B., this is educational code, optimized for simplicity; in a production system, we might support internal calls, at a higher gas cost, by putting those specific posts in storage instead

Multiple posts from the same user could be added to the same block. When this happens, we don’t want to waste resources writing the block number more than once.

        // Only add the block number if we don't already have a post
        // in this block
        uint length = MessageBlocks[msg.sender].length;

If the list is empty, then of course the current block isn’t in it.

        if (length == 0) {
            MessageBlocks[msg.sender].push(block.number);

If the list is not empty then the last entry is at index length-1. If any entry in the list is the current block, it will be the last entry. Because block serial numbers only increase, it’s enough to check whether the last entry is smaller than the current block number.

        } else if (MessageBlocks\[msg.sender\][length-1] < block.number) {

We use the push function to add a value to the end of an array.

            MessageBlocks[msg.sender].push(block.number);
        }
    }   // function post

This function returns the block list for a specific sender.

    function getSenderMessages(address sender) public view 
        returns (uint[] memory) {
        return MessageBlocks[sender];
    }   // function getSenderMessages

}   // contract Chirper

Creating the JavaScript API

Now we’ll create the JavaScript API to enable users to interact with the smart contract.

Putting the JavaScript API in a separate module would complicate things needlessly. Instead, you can see the code at the top of the tests file in the GitHub repo.

The message data is sent in the format of hexadecimal digits representing the ASCII code of the message’s characters, padded with zeros to be an integer multiple of 64 characters in length. For example, if the message is Hello, the data we get is "48656c6c6f0000…0000".

We use the following function to convert the data we get from the ethers.js library to a normal string:

const data2Str = str => {

First, we split this single string into an array using String.match and a regular expression.

[0-9a-f] matches a single hexadecimal digit; the {2} tells the program to match two of them. The slashes around the [0-9a-f]{2} tell the system that this is a regular expression. The g specifies that we want a global match, to match all the substrings that match the regular expression rather than just the first one.

After this call we have an array, ["48", "65", "6c", "6c", "6f", "00", "00" … "00"]

  bytes = str.match(/[0-9a-f]{2}/g)

Now, we need to remove all those padding zeros. One strategy is to use the filter function. filter receives a function as input, and returns only those members of the array for which the function returns true. In this case, only those array members that are not equal to "00".

  usefulBytes = bytes.filter(x => x != "00")

The next step is to add 0x to each hexadecimal number so that it will be interpreted correctly. To do this we use the map function. Map also takes a function as input, and it runs that function on every member of the array, returning the results as an array. After this call we have ["0x48", "0x65", "0x6c", "0x6c", "0x6f"]

  hexBytes = usefulBytes.map(x => '0x' + x)

Now, we need to turn the array of ASCII codes into the actual string. We do this using String.fromCharCode. However, that function expects a separate argument for each character, instead of an array. The syntax ..["0x48", "0x65", "0x63" etc.] turns the array members into separate arguments.

  decodedStr = String.fromCharCode(...hexBytes)

Finally, the first six characters aren’t really the string, but the metadata (e.g., the string length). We do not need these characters.

  result = decodedStr.slice(6)
  return result
}  // data2Str

Here’s the function that gets all the messages from a particular sender on a particular Chirper contract:

const getMsgs = async (chirper, sender) => {

First, we call Chirper to get the list of blocks that contain relevant messages. The getSenderMessages method returns an array of integers, but because Ethereum integers can range up to 2^256-1, we receive an array of BigInt values. The .map(x => x.toNumber()) turns them into normal numbers that we can use to retrieve the blocks.

  blockList = (await chirper.getSenderMessages(sender)).map(x => x.toNumber())

Next, we retrieve the blocks. This operation is a bit complicated, so we’ll take it step-by-step.

To retrieve a block, both the header and the transactions, we use the ethers.js function provider.getBlockWithTransactions().

JavaScript is single-threaded, so this function returns immediately with a Promise object. We could tell it to wait by using async x => await ethers…, but that would be inefficient.

Instead, we use map to create a whole array of promises. Then we use Promise.all to wait until all of the promises are resolved. This gives us an array of Block objects.

  blocks = await Promise.all(
    blockList.map(x => ethers.provider.getBlockWithTransactions(x))
  )

timestamp is a function of the block, not the transaction. Here we create a mapping from the block numbers to the timestamps so that we can add the timestamp to the message later. The block number is included in every Transaction object.

  // Get the timestamps
  timestamps = {}
  blocks.map(block => timestamps[block.number] = block.timestamp)

Every Block object includes a list of transactions; however, map gives us an array of transaction arrays. It is easier to deal with transactions in a single array, so we use Array.flat() to flatten it.

  // Get the texts
  allTxs = blocks.map(x => x.transactions).flat()

Now we have all the transactions from the blocks that contain the transactions we need. However, most of these transactions are irrelevant. We only want transactions from our desired sender, whose destination is Chirper itself.

If Chirper had multiple methods, we would have needed to filter on the first four bytes of the data which control what method is called. But as Chirper only has one method, this step is not necessary.

  ourTxs = allTxs.filter(x => x.from == sender && x.to == chirper.address)

Finally, we need to convert the transactions into useful messages, using the data2Str function we defined earlier.

  msgs = ourTxs.map(x => {return {
    text: data2Str(x.data),
    time: timestamps[x.blockNumber]
  }})

  return msgs
}   // getMsgs

This function posts a message. In contrast to getMsgs, it is a trivial call to the blockchain. It waits until the transaction is actually added to a block so that the order of messages will be preserved.

const post = async (chirper, msg) => {
  await (await chirper.post(msg)).wait()
}    // post

Testing the contract with Waffle and Chai

We’ll write the contract tests with Waffle, a smart contract testing library that works with ethers.js. Refer to this tutorial to learn more about using Waffle to test Ethereum contracts.

We’ll use the Chai library for testing.

const { expect } = require("chai")

The way Chai works is that you describe various entities (in this case, the Chirper contract) with an async() function that either succeeds or fails.

describe("Chirper",  async () => {

Inside the describe function you have it functions that are the behaviors the entity is supposed to have.

  it("Should return messages posted by a user", async () => {
    messages = ["Hello, world", "Shalom Olam", "Salut Mundi"]

Our first step is to deploy the Chirper contract using a ContractFactory, like so:

    Chirper = await ethers.getContractFactory("Chirper")
    chirper = await Chirper.deploy()

Next, we post all the messages in the array. We await for each posting to happen before doing the next one so we won’t get messages out of order and cause the below equality comparison to fail.

    for(var i=0; i<messages.length; i++)
      await post(chirper, messages[i])

The getSigners function gets the accounts for which our client has credentials. The first entry is the default, so it’s the one the post function used.

    fromAddr = (await ethers.getSigners())[0].address

Next, we call getMsgs to get the messages.

    receivedMessages = await getMsgs(chirper, fromAddr)

This use of map lets us check if the list of messages we sent is equal to the list of messages we received. The function inside map can accept two parameters: the value from the list and its location.

    messages.map((msg,i) => expect(msg).to.equal(receivedMessages[i].text))

  })   // it should return messages posted ...

It isn’t enough to retrieve the correct messages. To show that the application works correctly, we also have to show that messages that should be filtered out are actually filtered out.

  it("Should ignore irrelevant messages", async () => {  
    Chirper = await ethers.getContractFactory("Chirper")
    chirper1 = await Chirper.deploy()

To create messages that come from a different address, we need to get the wallet for that address.

    otherWallet = (await ethers.getSigners())[1]

Then, we use the connect function to create a new Chirper contract object with the new signer.

    chirper1a = chirper1.connect(otherWallet)    

Messages sent from the address we’re looking for, but to another chirper instance, are also irrelevant.

    chirper2 = await Chirper.deploy()
    await post(chirper1, "Hello, world")
    // Different chirper instance
    await post(chirper2, "Not relevant")
    // Same chirper, different source address
    await post(chirper1a, "Hello, world, from somebody else")
    await post(chirper1, "Hello, world, 2nd half")
    await post(chirper2, "Also not relevant (different chirper)")
    await post(chirper1a, "Same chirper, different user, also irrelevant")

    receivedMessages = await getMsgs(chirper1, 
        (await ethers.getSigners())[0].address)
    expected = ["Hello, world", "Hello, world, 2nd half"]
    expected.map((msg,i) => expect(msg).to.equal(receivedMessages[i].text))     
  })   // it should ignore irrelevant messages

}) //describe Chirper

Conclusion

In this article, we demonstrated how to build a decentralized social media platform, Chirper, that runs on the Ethereum blockchain.

In contrast to Facebook or Twitter, posting messages to our blockchain platform will cost the user. This is to be expected. Decentralized systems cost more than their centralized counterparts. So, it would probably be unlikely that we’d see as many cat pictures or political memes as we do on free social networks!

On the other hand, our Chirper blockchain social media platform offers some advantages.

First, it is a permanent unalterable record. Second, the data is publicly available on-chain, and the business logic is open source, uncoupled from the user interface. This means that instead of being tied to a specific provider’s user interface, users can switch UIs while retaining access to the same data.

Since a better user interface will not need to struggle against network effects to gain acceptance, there will be much more room for experimentation and competition, resulting in a better user experience.

I hope you enjoyed this article.

Join organizations like Bitso and Coinsquare who use LogRocket to proactively monitor their Web3 apps

Client-side issues that impact users’ ability to activate and transact in your apps can drastically affect your bottom line. If you’re interested in monitoring UX issues, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web and mobile apps — .

Ori Pomerantz Ori has been securing IT, and teaching others to do the same, since the start of his career in 1995. In addition to raising seven kids with his wife, he works @optimismPBC and writes about Ethereum on the side.

Leave a Reply