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.
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.
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.
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.
Next, we need to determine how a user will post messages and how they will find messages.
Posting a message requires two actions:
This is the flow we’ll use to implement message posting:
This is the flow that we’ll use to enable a user to find, read, and interpret messages:
view
function (a read-only function), it only gets executed on a single node and does not cost any gasChirper
contract as the destinationThe 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).
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:
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
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
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
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.
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 — Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]