Let's build a Twitter clone on Web3 - A Comprehensive Guide to Building on Flow
Andrea Muttoni
Posted on December 2, 2022
Introduction
In this guide, we’ll be building a Web3 version of Twitter. By the end of it, you should have an understanding of what building “on Web3” means, how to write smart contracts that go beyond the typical JPEG NFT example, and hopefully walk away with inspiration and ideas on what you can build next.
You know that inspirational quote: "It's not the destination, it's the journey"? While the finished product is a reductio ad absurdum of a real Twitter app, the process along the way is what I want you to focus on. That is, building on-chain logic on a blockchain using smart contracts is relatively simple, and is a general purpose technology. My hope is that it helps cut through the hype and speculation, and shows you how the components work, so you can re-apply them to new use-cases.
Is this tutorial for me?
I had two choices when writing this: either start from a finished demo and explain how it works, or bring you along as I create the app from scratch and iterate over it and add complexity bit by bit. I've opted for the latter.
In other words, this is not a quick tutorial (here's one though), it's a somewhat comprehensive step-by-step guide meant to be followed in an afternoon or weekend.
Does that sound good to you? Great, let's dive in!
Wait...What is web3?
Let's start with the elephant in the room. Web3. What is it? You’ll likely get a different answer from everyone you ask. What follows is a definition, which is by no means exhaustive or prescriptive.
Web3 is a way to build experiences in a way that they benefit from composability and decentralization.
Composability means anyone can build on top of any existing experience. In “web2”, the classic way to offer composability is via APIs, and while APIs are great, they need to be built, maintained and are a conscious choice. In Web3, the smart contracts are open to all, and they themselves are the APIs. Even more importantly: every app built using smart contracts is effectively a “module” that you can import into your own app.
In traditional tech stacks, the libraries and packages are composable (think npm modules) - in web3, the applications are composable (think Twitter). That's the paradigm shift.
Practically, it means that when we deploy our Twitter clone to mainnet (the "live" production chain), anyone else can build experiences on top of it – not just UIs, but higher-order experiences such as games and extended functionality (e.g. Rate-my-tweet or Tweet tipping, etc)
Likewise, Web3’s decentralized nature means we can rest easy knowing no single entity (or billionaire) has the power to override our platform, both at the infrastructure level (the servers that run the code), as well as the content itself.
Why Flow?
When building a web3 application, the “tech stack” is usually composed of a traditional tech stack (e.g. servers, frontend frameworks, databases, etc), as well as a web3-specific stack that allows us to interact with a blockchain (cli's, client libraries, wallets).
Like all tech stacks, there’s no right or wrong tool for the job - just different ones, and each come with their unique characteristics. It really depends on what you believe are the most important factors. I recommend you follow this guide regardless of what blockchain you think is best, and then try to implement this same guide on your favorite blockchain as a challenge!
In this guide, we’ll be building a Twitter clone using the Flow blockchain. The main reasons behind choosing Flow are:
- Flow is used by some of the most mainstream web3 applications, like NBA Top Shot, Instagram and YouTube.
- When it comes to smart contracts, Cadence (Flow’s native smart contract programming language) is a safer, more readable language than Solidity or Rust, and features a resource-oriented paradigm that makes working with digital assets a lot more intuitive (i.e. you can’t lose or duplicate things accidentally like you can on other chains)
- Flow has a very intuitive account model whereby digital assets are stored in the respective accounts that own them - not centralized in a key/value map in the smart contract like Ethereum. This distributed ownership and improves performance and security.
- Flow has extremely low fees, which are usually subsidized by the wallets, meaning it’s effectively free to build and transact. Perfect for creating a bustling social network!
- Thanks to its scalability, storage is really cheap on Flow, meaning we can build it entirely on-chain. More on that later!
- Most importantly: Flow was built and designed from the ground up for composability and writing complex applications powered by smart contracts.
Read more about Flow here.
Getting Started
What we’re going to build
In this guide we’ll build a simplified version of Twitter. We'll start basic, and progressively enhance it. This will minimize the cognitive load, and help focus on novel concepts before we introduce additional abstractions/complexity.
What we'll cover
- Part 1 - Smart Contracts
- Learn Cadence basics by writing a smart contract (3 times!)
- Interact with the smart contract using scripts and transactions (i.e. reading and mutating the chain)
- Learn how to use the Flow Playground
- Part 2 - Building an web app & Tooling
- Authentication
- Flow Client Library
- Connect the smart contract to a simple web app in React/NextJS
- Develop locally using the Flow Emulator
- Learn about the Flow CLI and Dev Wallet
- Learn how to run scripts & transactions on the Flow CLI
- Deploying to testnet
Let's dive in, shall we?
Smart Contract v0 - A Very Basic Example: A single Tweet.
Given that most devs are familiar with a typical web stack, we'll start with the new interesting bit first - the smart contract. Writing smart contracts usually involves interacting with the chain via commands and CLIs. We'll definitely do that, but first, we'll start our journey in the Flow Playground, where the chain is emulated and abstracted away for the most part, so we can focus mainly on the smart contract logic.
Our first smart contract in Cadence
📖 What is Cadence? Cadence is Flow’s native smart contract programming language. As mentioned earlier, Cadence is a safe, powerful language, and features a resource-oriented paradigm that makes working with digital assets a lot more intuitive (i.e. you can’t lose or duplicate things accidentally like you could on other chains). You might think - "Oh no, a new language I need to learn". Don't worry, it's actually quite easy to grasp, and if you're at all used to TypeScript or Swift, you'll find it's very familiar.
This very first iteration of the contract only allows to create and store a single tweet. Not exciting, I know, but it gives me a chance to explain some fundamentals before we start introducing more complexity.
Before explaining what it does, try to read the code below line by line and see if you can make sense of it with out ANY context at all. I'll wait. (And yes, you are allowed to read the comments)
// Twitter-v0.cdc
//
pub contract Twitter {
// Declare the Tweet resource type
pub resource Tweet {
// The unique ID that differentiates each Tweet
pub let id: UInt64
// String mapping to hold metadata
pub var metadata: {String: String}
// Initialize both fields in the init function
init(message: String) {
self.id = self.uuid
self.metadata = {
"message": message
}
}
}
// Function to create a new Tweet
pub fun createTweet(_ message: String): @Tweet {
return <-create Tweet(message: message)
}
// Create a single new Tweet and save it to account storage
init() {
self.account.save<@Tweet>(<- self.createTweet("My first tweet!"), to: /storage/TwitterPath)
}
}
Ok, let's dissect it bit by bit.
Contract
pub contract Twitter {
With this line, we created a contract. We gave it the name "Twitter" because a contract is essentially a program that lives on the blockchain. Within a contract, functions and properties can exist. We can also import other contracts and use their functions and properties within our own contract, making them extremely composable. In our case, our Twitter contract will be the main contract of our application.
You'll see towards the end of the contract that there's a init()
function. When the contract is deployed (i.e. saved on the chain somewhere), it will be initialized and the init function will be called once.
Resources
pub resource Tweet {
Most other blockchains have what's called a central ledger. Basically a {key: value}
map property within the smart contract that keeps track of all the state. This means if anyone (malicious or not) has access to the contract, they can change that map however they like and modify the global state. Cadence has something called resources, which are special objects that are stored in users' own accounts. Anything can be a resource (e.g. NFTs), and resources benefit from the resource ownership rules that are enforced by the type system, that is: resources can only have a single owner, they cannot be duplicated, and they cannot be lost due to accidental or malicious programming errors. These protections ensure that unique digital assets, whether Tweets or NFTs, or whatever else, can be safely represented on Flow.
// The unique ID that differentiates each Tweet
pub let id: UInt64
// String mapping to hold metadata
pub var metadata: {String: String}
// Initialize both fields in the init function
init(message: String) {
self.id = self.uuid
self.metadata = {
"message": message
}
}
A resource is similar to a contract in that it can contain properties, functions, including the lifecycle init
function, which will be called when the resource is created.
📖 All composite types like contracts, resources, and structs can have an optional init() function that only runs when the object is initially created. Cadence requires that all fields must be explicitly initialized, so if the object has any fields, this function has to be used to initialize them. Source
In our case, the Tweet resource type has 2 properties: id
and metadata
. These are completely arbitrary. It just makes sense to give a unique resource an id, and the metadata object is a blank object that we can fill with whatever we need.
On Flow, all resources have a unique id automatically assigned to them, i.e. a uuid
property, that in this case we are using to set the "official" ID of our Tweet. This ensures uniqueness. We could also create our ID from scratch, or use an incrementing counter, but uuid
is generally recommended (at least IMHO!).
When we create the resource we're going to pass in an argument called message, which we will use to populate the metadata object with a key called message. Here's this exact process in action:
// Function to create a new Tweet
pub fun createTweet(_ message: String): @Tweet {
return <-create Tweet(message: message)
}
In this snippet, which belongs within the Twitter contract, is a function that creates a new Tweet. The Twitter contract is so basic that we don't even need it, as we only call it once in the init
, but it shows you how to create a function. What's more interesting is what happens inside of it. You see we create
a new Tweet resource (resources have to be explicityly create
d or destroy
ed with the respective keywords), or moved (read on!)
There's also bunch of weird symbols _
, @
, <-
. Let's see what they do in order:
-
_
: just means it's the default argument, so we don't need to add a "message" label when we create the Tweet. -
@
: what comes after the colon in the function definition is the expected return type. In this case the @ symbol means it's a resource. -
<-
: looks like an arrow right? It is! The move operator in Cadence, through which resources are passed around the execution state. Before the transaction ends the resource must be stored somewhere or destroyed otherwise Cadence will throw an error. More on that later.
👍 If you find any more weird symbols, just look them up in the Glossary (Fun fact: I contributed the first version of that Glossary when I started building on Flow because I also was confused by all the symbols!).
// Create a single new Tweet and save it to account storage
init() {
self.account.save<@Tweet>(<- self.createTweet("My first tweet!"), to: /storage/TwitterPath)
}
Finally, we get to the contract's own init
method. Within it, the contract creates a Tweet upon initialization, containing the message "My first tweet!" and stores it within the account in a custom path called TwitterPath
. Think of account storage a bit like directories, we need to create our custom "folder" within which to save our resource. These names should be unique. Read more about Cadence's Account Storage and storing data on Flow.
📖 If you want to dive a little deeper into Cadence and Flow's account-based ownership read these posts:
Flow Playground - Deploying and interacting with our contract
Looking at code is cool, but running it is even cooler. Let's now interact with the contract. To do that, we'll use the Flow Playground. Click on the image or the link below to go to the live Playground example.
👉 Click me to view in Playground
- Click on
0x01
andDeploy
the contract in the top right.
- Once you deploy the contract you should see a confirmation like the following.
What did we do? We deployed the contract to the emulated chain to the 0x01
account. This means the contract is now "running" on that account and can be called from other accounts or imported into other smart contracts.
IMPORTANT: the playground is an emulator, this means it's self-contained. If we wanted other real users to interact with it, we would need to deploy this to testnet or mainnet. We'll get to that, don't worry!
Now let's "see" the resource we created. Click on the database icon on the right of 0x01
:
A popup should appear like the following:
Here's the full JSON:
{
"Fields": [
38,
38,
{
"DictionaryType": {
"ElementType": {},
"KeyType": {}
},
"Pairs": [
{
"Key": "message",
"Value": "My first tweet!"
}
]
}
],
"ResourceType": {
"Fields": [
{
"Identifier": "uuid",
"Type": {}
},
{
"Identifier": "id",
"Type": {}
},
{
"Identifier": "metadata",
"Type": {
"ElementType": {},
"KeyType": {}
}
}
],
"Initializers": null,
"Location": {
"Address": "0x0000000000000005",
"Name": "Twitter",
"Type": "AddressLocation"
},
"QualifiedIdentifier": "Twitter.Tweet"
}
}
Nothing too crazy right? It's just a very verbose JSON object, containing the properties we expect of our resource.
Running a Transactions
If you click on the first Transaction "Check if Tweet exists"
In general, Transactions are signed and they mutate the chain, whereas Scripts only read the chain, meaning they can have no side-effect on the chain itself.
Think of transactions like authorized
POST
requests, and scripts like unauthorizedGET
requests to a traditional backend.
In our case, we will run a transaction to look within our own contract and check that the Tweet resource exists.
Click on "Send".
You should see "Tweet exists" in the transaction results.
Tweet again or die tryin'
Let's try to create a new Tweet, just to show you why it's not possible with our current contract.
There's a second transaction called "Create new Tweet". Here's the code for it:
// (try to) Create new tweet
import Twitter from 0x01
// This transaction checks if a tweet exists in the storage of the given account
// by trying to borrow from it. If the borrow succeeds (returns a non-nil value), the token exists!
transaction {
prepare(acct: AuthAccount) {
acct.save<@Twitter.Tweet>(<-Twitter.createTweet("This is my second tweet"), to: /storage/TwitterPath)
}
}
If you try to run this transaction, which is more or less identical to the code in the contract's init
function, we'll get an error:
As you can infer from the error, when we initialized the contract, we stored our first Tweet resource in a location (TwitterPath
), and now that location cannot be re-used for another Tweet, as it would overwrite the other Tweet. Dang! How do we fix this? We'll cover this in the next section :)
Summary
Let's recap: in this section we created a super basic contract to showcase some initial key features of Cadence, and we interacted with the code in the Flow Playground, an emulated environment that abstracts away a lot of the complexity like wallets.
Despite having started with a very basic example, it's a LOT to cover. So don't worry if you still don't get all of it. I've purposely created this tutorial in phases, so that at every phase, we will cover old ground and will help you get it.
Smart Contract v1 - Basic Example: Multiple Tweets
Alright, we've covered the super basic example, now let's start going a bit deeper in the Cadence rabbit hole and introduce some new concepts.
First off, let's fix one of the biggest, glaring issues with our first iteration of the Twitter contract: it only saves 1 tweet 😅
Something cool about resources is that resources can contain other resources. On Flow resources that are made to contain the same type of something are most commonly called Collections. Collections are none other than a resource that we create that will be responsible for storing other resources (our Tweets). That way, rather than storing a single Tweet in our Twitter storage path, we will create and store a collection resource, and the collection will expose dedicated methods to write/read/remove resources (Tweets) contained within.
👉 Fun fact: on Flow, NFT projects use collections to allow accounts to store multiple NFTs of the same project within their accounts. A completely on-chain NFT Catalogue helps ensure other dapps like Marketplaces or wallets know what collection (and location) to search for when trying to load a specific projects' NFTs.
Adding a Collection Resource
In this section we will do the bare-minimum to get a collection working. We'll significantly enhance it later.
// Twitter-v1.cdc
// Still basic, but with multiple tweets!
pub contract Twitter {
// Declare a Path constant so we don't need to harcode in tx
pub let TweetCollectionStoragePath: StoragePath
// Declare the Tweet resource type - nothing changed here!
pub resource Tweet {
// The unique ID that differentiates each Tweet
pub let id: UInt64
// String mapping to hold metadata
pub var metadata: {String: String}
// Initialize both fields in the init function
init(message: String) {
self.id = self.uuid
self.metadata = {
"message": message
}
}
}
// Function to create a new Tweet
pub fun createTweet(_ message: String): @Tweet {
return <-create Tweet(message: message)
}
// NEW!
// Declare a Collection resource that contains Tweets.
// it does so via `saveTweet()`,
// and stores them in `self.tweets`
pub resource Collection {
// an object containing the tweets
pub var tweets: @{UInt64: Tweet}
// a method to save a tweet in the collection
pub fun saveTweet(tweet: @Tweet) {
// add the new tweet to the dictionary with
// a force assignment (check glossary!)
// If there were to be a value at that key,
// it would fail/revert.
self.tweets[tweet.id] <-! tweet
}
// get all the id's of the tweets in the collection
pub fun getIDs(): [UInt64] {
return self.tweets.keys
}
init() {
self.tweets <- {}
}
destroy() {
// when the Colletion resource is destroyed,
// we need to explicitly destroy the tweets too.
destroy self.tweets
}
}
// create a new collection
pub fun createEmptyCollection(): @Collection {
return <- create Collection()
}
init() {
// assign the storage path to /storage/TweetCollection
self.TweetCollectionStoragePath = /storage/TweetCollection
// save the empty collection to the storage path
self.account.save(<-self.createEmptyCollection(), to: self.TweetCollectionStoragePath)
}
}
Let's break down what changed. Here's the diff to make it easier to spot the differences.
What you'll notice is the Tweet
resource and createTweet
function have not changed. Here are the changes:
- a Path constant (line 7) - by defining it, we won't have to hard code it in our transactions and scripts.
- a new
Collection
resource (lines 26-56) along with acreateEmptyCollection()
function to create a new (empty) collection. - changed the
init()
, to assign the newly created Path constant, and then make use of it in the next line where we create a new collection and save it in the account.
There's a couple new things on the Cadence side here:
Collection
pub resource Collection {
// an object containing the tweets
pub var tweets: @{UInt64: Tweet}
The Collection resource contains a dictionary of Resources called tweets
(identified by the @{...}
). In our case, the keys are the Tweet IDs, and the values are the Tweet resources.
Now let's how we populate that dictionary.
Modifying a resource dictionary
// a method to save a tweet in the collection
pub fun saveTweet(tweet: @Tweet) {
// add the new tweet to the dictionary with
// a force assignment (check glossary!)
// If there were to be a value at that key,
// it would fail/revert.
self.tweets[tweet.id] <-! tweet
}
The function called saveTweet
, expects a Tweet resource and saves it in the resource dictionary. You'll notice a weird exclamation mark (!) next to the move (<-) operator. This is called a force-assignment move operator which assigns a resource to an optional variable. The result of a dictionary read is optional, as the given key might not exist in the dictionary. In this case, we actually need it to be nil, otherwise the force-assignment will fail.
📖 Don't worry, you'll get a hand for optionals as we go through the course. Also, when you see question marks in transaction code, you're likely looking at optional chaining.
Let's move to the second half of the Collection resource:
...
// get all the id's of the tweets in the collection
pub fun getIDs(): [UInt64] {
return self.tweets.keys
}
init() {
self.tweets <- {}
}
destroy() {
// when the Colletion resource is destroyed,
// we need to explicitly destroy the tweets too.
destroy self.tweets
}
}
getIDs()
uses a built-in property of dictionaries/maps in Cadence to return all the keys. You guessed it: it's .keys
. We'll use this function later to check how many tweets are saved in the account when we interact with the contract.
In the init
method we simply initialize tweets to be an empty resource dictionary.
The destroy
method is new. If the Collection resource is destroyed with the destroy command, it needs to know what to do with the resources it stores in the dictionary. This is why resources that store other resources have to include a destroy function that runs when destroy is called on it. This destroy function has to either explicitly destroy the contained resources or move them somewhere else. In this example, we destroy them.
Updated Contract init function
As mentioned above, in the init function we assign and make use of our new Path constant. Rather than creating a tweet right off the bat, we create a new collection, and we'll create Tweets using transactions in just a second!
init() {
// assign the storage path to /storage/TweetCollection
self.TweetCollectionStoragePath = /storage/TweetCollection
// save the empty collection to the storage path
self.account.save(<-self.createEmptyCollection(), to: self.TweetCollectionStoragePath)
}
Alright, with the contract explained, let's play!
Flow Playground - Deploying and interacting with the new contract
Let's interact with the new and improved contract. We'll of course jump back to the Flow playground, but with a new link. Click on the image or the link below to go to the updated live playground example.
👉 Click me to go to the Playground Example
Creating Tweets
Rather than creating a Tweet automatically in the contract initialization step, now we'll purely create them through a transaction. What's more, is that our transaction will accept an argument with will be the message of the Tweet.
You can view the transaction code by clickin gon "Create new Tweet" in the Transaction templates of the Playground.
Here's the code if you're lazy:
// Create new Tweet
import Twitter from 0x01
// This transaction creates a new tweet with an argument
transaction (message: String) {
// Let's check that the account has a collection
prepare(acct: AuthAccount) {
if acct.borrow<&Twitter.Collection>(from: Twitter.TweetCollectionStoragePath) != nil {
log("Collection exists!")
} else {
// let's create the collection if it doesn't exist
acct.save<@Twitter.Collection>(<-Twitter.createEmptyCollection(), to: Twitter.TweetCollectionStoragePath)
}
// borrow the collection
let collection = acct.borrow<&Twitter.Collection>(from: Twitter.TweetCollectionStoragePath)
// call the collection's saveTweet method and pass in a Tweet resource
collection?.saveTweet(tweet: <-Twitter.createTweet(message))
log("Tweet created successfully, with message ".concat(message))
}
}
Right off the bat, differently from our previous transaction where we attempted to create a second tweet (which failed), this new transaction accepts an argument, called message
. We'll pass that argument to the createTweet
function.
You notice we only interact with the Collection resource now, and the collection resource will be responsible for managing the tweets contained within it.
If we were to break up the transaction into rough logic chunks:
- Check if collection exists in account running transaction
- If collection exists, log "collection exists" (just fyi)
- If collection does NOT exist, create one in that account
- Borrow the collection to call saveTweet, and pass in a newly created Tweet using the createTweet function.
You may be asking yourself - wait - why are we checking for a collection if we automatically create one when we initalize the contract? Well, because that only happens on the account where the contract is deployed to. Other accounts can interact with the contract and create their own tweets. So if any other account wants to create a Tweet, they first need a collection. So we must always check for a collection to be present whenever we are creating/saving a Tweet inside someones account.
Let's create our first Tweet:
You should see:
Let's create our second one:
You should see:
Counting Tweets in an Account
Now to double check, let's run a second transaction. You'll find it right under the first one in the Playground template. Get tweets
which calls the Collection's getIDs
method. It will the length of the array that is returned by getIDs
all the Tweet IDs contained in the collection.
// Get tweets
import Twitter from 0x01
// This transaction retrieves all Tweets
transaction {
// Let's check that the account has a collection
prepare(acct: AuthAccount) {
let collection = acct.borrow<&Twitter.Collection>(from: Twitter.TweetCollectionStoragePath)
if collection != nil {
log (collection?.getIDs()?.length)
} else {
log ("Collection does not exist")
}
}
}
If you run it, you should get the following output:
Note: the number may vary if you created more than 2 tweets!
Creating tweets with another account
Now want to see something cool?
Rather than running the Create new Tweet
with 0x01
, try selecting any other account (e.g. 0x02
) as the signer. You'll see it doesn't log "Collection exists", as it will create the collection and then create the tweet successfully. Now 0x02
has a Tweet Collection! Yay.
Summary & Limitations with our current implementation
Great, our contracts now supports a collection and we can now create multiple tweets. However, other accounts can't read tweets from any other accounts but themselves. This is because we are working with storage directly, which only the owning account has access to.
How do we get access to other accounts' tweets? The bad news is we need to upgrade our contract (AGAIN). The good news is we get to learn some cool new Cadence features! What we need to do is to start creating public capabilities. Capability-based access control is one of Cadence's most powerful features and will regulate access rights outside of the contract and owning account. In our case, we want to be able to read tweets from other accounts, but not create tweets on behalf of other accounts.
This will also be a great opportunity to introduce the concept of interfaces. Let's get to it!
Smart Contract v2 - Less Basic: Letting other people view our tweets
Interfaces and Capabilities
To allow other accounts (or scripts, which are essentially un-authenticated requests) to access our tweets, we need to do two things:
- Create an public-facing interface that our Collection resource will conform to
- Create a capability to access the Collection through the public-facing interface
Let's tackle these in order. Here's a formal definition from the docs.
📖 Interfaces: An interface is an abstract type that specifies the behavior of types that implement the interface. Interfaces declare the required functions and fields, the access control for those declarations, and preconditions and postconditions that implementing types need to provide.
There are three kinds of interfaces:
- Structure interfaces: implemented by structures
- Resource interfaces: implemented by resources
- Contract interfaces: implemented by contracts
📖 Capabilities: if an account wants to be able to access another account's stored objects, it must have a valid capability to that object. Capabilities are identified by a path and link to a target path, not directly to an object. Capabilities are either public (any user can get access), or private (access to/from the authorized user is necessary). Public capabilities are created using public paths, i.e. they have the domain public. After creation they can be obtained from both authorized accounts (AuthAccount) and public accounts (PublicAccount).
It's easier to explain by showing you the code, so if it's still confusing, keep on reading!
Updating the contract with interfaces and capabilities
Here's the new contract code. Scroll down for a diff and we'll cover each change bit by bit!
// Twitter-v2.cdc
// Still basic, but with multiple tweets (and capabilities!)
pub contract Twitter {
// Declare a Path constant so we don't need to harcode in tx
pub let TweetCollectionStoragePath: StoragePath
pub let TweetCollectionPublicPath: PublicPath
// Declare the Tweet resource type - nothing changed here!
pub resource Tweet {
// The unique ID that differentiates each Tweet
pub let id: UInt64
// String mapping to hold metadata
pub var metadata: {String: String}
// Initialize both fields in the init function
init(message: String) {
self.id = self.uuid
self.metadata = {
"message": message
}
}
}
// Function to create a new Tweet
pub fun createTweet(_ message: String): @Tweet {
return <-create Tweet(message: message)
}
pub resource interface CollectionPublic {
pub fun getIDs(): [UInt64]
pub fun borrowTweet(id: UInt64): &Tweet?
}
// NEW!
// Declare a Collection resource that contains Tweets.
// it does so via `saveTweet()`,
// and stores them in `self.tweets`
pub resource Collection: CollectionPublic {
// an object containing the tweets
pub var tweets: @{UInt64: Tweet}
// a method to save a tweet in the collection
pub fun saveTweet(tweet: @Tweet) {
// add the new tweet to the dictionary with
// a force assignment (check glossary!)
// If there were to be a value at that key,
// it would fail/revert.
self.tweets[tweet.id] <-! tweet
}
// get all the id's of the tweets in the collection
pub fun getIDs(): [UInt64] {
return self.tweets.keys
}
pub fun borrowTweet(id: UInt64): &Tweet? {
if self.tweets[id] != nil {
let ref = (&self.tweets[id] as &Twitter.Tweet?)!
return ref
}
return nil
}
init() {
self.tweets <- {}
}
destroy() {
// when the Colletion resource is destroyed,
// we need to explicitly destroy the tweets too.
destroy self.tweets
}
}
// create a new collection
pub fun createEmptyCollection(): @Collection {
return <- create Collection()
}
init() {
// assign the storage path to /storage/TweetCollection
self.TweetCollectionStoragePath = /storage/TweetCollection
self.TweetCollectionPublicPath = /public/TweetCollection
// save the empty collection to the storage path
self.account.save(<-self.createEmptyCollection(), to: self.TweetCollectionStoragePath)
// publish a reference to the Collection in storage
self.account.link<&{CollectionPublic}>(self.TweetCollectionPublicPath, target: self.TweetCollectionStoragePath)
}
}
Diff
You'll notice, not too much has changed. Here's what I added:
- Added a new Path constant called
TweetCollectionPublicPath
, that we will reference later to expose the Collection publically. - Added a new resource interface
Collection Public
, which I then assign to theCollection
resource. - Created a new method
borrowTweet
in the Collection that returns a reference to the Tweet based on a Tweet ID. - Updated the contract
init()
function to- initialize the new Path constant above
- save a capability to access the collection within the scope of the Collection Public interface.
Public Collection Interface
Let's start with the Collection Public
.
pub resource interface CollectionPublic {
pub fun getIDs(): [UInt64]
pub fun borrowTweet(id: UInt64): &Tweet?
}
What we're doing here is describing a restricted version of the Collection that is for public consumption. As you can see, the only two methods included in this interface are getIDs
and borrowTweet
(a new method which we still need to look at - soon!).
How do we ensure that our Collection conforms to this interface? By adding it to the Collection declaration similar to how we add types to variables.
pub resource Collection: CollectionPublic {
Resources can implement multiple interfaces. You should see interfaces in two ways:
1) ensure a minimum set of functionality is implemented, ensuring compatibility
2) ensure only a subset of the original functionality is within scope, ensuring that other methods or properties will not be included.
In our specific case, we are using interfaces mainly for reason #2.
So how do we use this interface to grant the public a restricted access to our collection?
Look at the last line (90) of the contract, within the init()
:
self.account.link<&{CollectionPublic}>(self.TweetCollectionPublicPath, target: self.TweetCollectionStoragePath)
The line above creates a public capability that allows access to the stored Collection resource but only through the type {CollectionPublic}
, i.e. only the functionality outlined in the interface. You can think of it as Collection
being "stripped" of anything other than what is strictly defined in CollectionPublic
before it is made available.
You can see how this can create powerful and flexible access controls. We can create interfaces and capabilities for various actors/counterparts within our application and Cadence will do the hard work of enforcing control.
⚠️ It's not terribly important in this example, but in the code above,
&{CollectionPublic}
is the equivalent of writing&AnyResource{CollectionPublic}
. That is, any resource that conforms to theCollectionPublic
interface will be valid. If we want to scope it to only accept the collection resource that we created, we would have to re-write it as&Collection{CollectionPublic}
. This would guarantee that not only does the resource have to conform to the interface, but it needs to be the specific underlying resource.
Borrowing a resource
So we've created a capability that allows us access to two methods of the collection: getIDs
and borrowTweet
. The latter is new, so let's take a look at it.
pub fun borrowTweet(id: UInt64): &Tweet? {
if self.tweets[id] != nil {
let ref = (&self.tweets[id] as &Twitter.Tweet?)!
return ref
}
return nil
}
🚨 New symbol alert! What the heck is &
? The ampersand (&) represents a reference
to a resource. Rather than loading the resource from storage and passing it back and forth, in Cadence we can create references to any object (both resources or structures). A reference can be used to access fields and call functions on the referenced object as a value type.
References are created by using the &
operator, followed by the object, the as
keyword, and the type through which they should be accessed. The given type must be a supertype of the referenced object's type.
If we zero in on the line let ref = (&self.tweets[id] as &Twitter.Tweet?)!
, this is exactly what we do: we create a reference of the Tweet resource at a certain ID, and cast it as a Tweet type. Since the ID could not exist, the dictionary returns an optional, so we use the exclamation mark at the end to force unwrap it.
Flow Playground - Deploying and interacting with the new contract
Let's interact with the new and improved contract. Click on the image or the link below to go to the updated live playground example.
👉 Click me to go to the Playground Example
First things first deploy the contract!
Updating the transaction
We still have the same transactions as last time, although you'll notice the Create new Tweet
transaction has an extra line:
// publish a reference to the Collection in storage
acct.link<&{Twitter.CollectionPublic}>(Twitter.TweetCollectionPublicPath, target: Twitter.TweetCollectionStoragePath)
This is the same line that we added to the init of the contract. This will ensure all other accounts that interact with the Twitter contract will also link the public capability.
Create a couple random Tweets from 0x01
and 0x02
in the same way we did in the previous section.
Structs, and running our first script
Scripts query the chain without affecting its state. Since there is no risk of side-effects, scripts can be executed by anyone, similar to an unauthorized GET request. Similar to how in a normal server you have to expose a port and serve specific data, on Flow we used the public capability to expose our collection, which the script below will make use of.
import Twitter from 0x01
pub struct TweetMetadata {
pub let id: UInt64
pub let message: String
init(id: UInt64, message: String) {
self.id = id
self.message = message
}
}
// Get tweets owned by an account
pub fun main(account: Address): [TweetMetadata] {
// Get the public account object for account
let tweetOwner = getAccount(account)
// Find the public capability for their Collection
let capability = tweetOwner.getCapability<&{Twitter.CollectionPublic}>(Twitter.TweetCollectionPublicPath)
// borrow a reference from the capability
let publicRef = capability.borrow()
?? panic("Could not borrow public reference")
// get list of tweet IDs
let tweetIDs = publicRef.getIDs()
let tweets: [TweetMetadata] = []
for tweetID in tweetIDs {
let tweet = publicRef.borrowTweet(id: tweetID) ?? panic("this tweet does not exist")
let metadata = TweetMetadata(id: tweet.id, message: tweet.metadata["message"]!)
tweets.append(metadata)
}
return tweets
}
A very basic script only needs to implement a pub fun main()
function. In our script you'll notice we also define a struct
called TweetMetadata
. This is not really required, but I just wanted to show you what structs are like.
Similar to resources, structs are composite types, in that they can implement interfaces and also can have an init function. The main difference is structs are regular value types meaning they don't need to be created/moved/destroyed like a resource would.
In our case, we are using this struct to create an intermediate abstraction of what the contents of a Tweet should be. This means that if our contract changes, we just need to change our TweetMetadata struct and anything downstream that relies on it will just work.
Note: in future sections we'll continue to iterate on this contract and make the metadata even more elegant by using what's called the Metadata Standard called MetadataViews
The rest of the script is relatively straightforward:
- Get the public account object using the built-in
getAccount
method, using the account passed in as the argument of the main function. - From that account, we borrow the
CollectionPublic
capability (if it doesn't exist, we panic, meaning the script will abort) - If it exists, we call the
getIDs
the same way we call it in our transaction - using the Cadence looping function
for ... in ... {}
, we iterate over every ID - In each iteration of the loop, we use the Collection's
borrowTweet
method to populate a new TweetMetadata struct and append it to atweets
array which will be our return value of the script.
Try running the script! Just select it in the Script Templates.
Similar to the transaction send window, you'll see a script execution window where we can specify any arguments. Since our script
If you've created at least 1 tweet with 0x01, you should see a script result like the following:
Here it is pretty-formated:
It seems like a complicated object, but the good news is "in real life" when we use this script in our app or via the Flow CLI, the object is "flattened" and types are inferred, so we'll work with a regular object containing the contents we expect (id and message).
Summary
In this third iteration of the contract, we added public capabilities to our collection, allowing anyone to access a public version, so that when we build our first POC app, we can view tweets from other people!
I think we've spent enough time on the Playground for now. Let's start building an app so we can see how all this fits together.
Building a first app with the v2 contract
Preface
In this section, we'll build an app and see how to interact with the chain. First, I just want to make something clear that the whole point of this tutorial is not to show you how much better building on the blockchain is compared to using traditional servers. It's to help you understand blockchain concepts applied to a use-case that we're all used to, regardless of whether you're in the web3 space or not.
Much like we iterated on the contract several times in the previous sections, in this section we will iterate back and forth both on the contract and web app as we add more and more features. I really hope you enjoy this "progressive enhancement" type of learning. It's more verbose than jumping right to the final version, but I think makes for a gentler introduction and helps you absorb concepts a bit more gradually.
Here's a sneak peek of the app:
Our app's tech stack
Let's pick what web stack to use.
Web Framework - NextJS, an "obvious" choice
To develop our app, we'll use a web framework. React/NextJS is the industry standard, so we'll use that.
😅 I'm a Svelte fan - React has weird quirks like JSX, that mixes HTML and CSS in the Javascript..who thought that was a good idea?! I'll probably build a Svelte version of this as well in case anyone reading prefers that, and any contributions in other
Minimal CSS styling
I opted to use pico.css
for the styling. It's a drop-in classless CSS framework, meaning it styles the base HTML tags, so we can use good ol' semantic HTML and have styles for free. Cool!
Flow Client Library - the perfect choice
Regardless of what web framework you use, one choice is already made for us. The JS Flow Client Library.
The Flow Client Library (FCL) JS is a package used to interact with user wallets and the Flow blockchain. When using FCL for authentication, apps are able to support all FCL-compatible wallets on Flow and their users without any custom integrations or changes needed to their code. Sweet!
FCL was created to make developing applications that connect to the Flow blockchain super easy and secure. It defines a standardized set of communication patterns between wallets, applications, and users that is used to perform a wide variety of actions for your dapp.
Note: While FCL itself is a concept and standard, FCL JS is the javascript implementation of FCL and can be used in both browser and server environments. All functionality for connecting and communicating with wallet providers is restricted to the browser. We also have FCL Swift implementation for iOS, see FCL Swift, contributed by @lmcmz.
Flow CLI - the one stop shop for all things Flow
Lastly, one other important tool we're going to use is the Flow CLI - a command-line interface that provides useful utilities for building Flow applications. It includes several commands to interact with Flow networks, such as querying account information, deploying contracts, sending transactions and executing scripts. It also includes the Flow Emulator and Dev Wallet (which is a development version Flow wallet used when developing locally on the emulator).
Setting up our dev environment
Prerequisites
Before moving to the next step, ensure you have the following tools installed:
NodeJS
Ensure you have NodeJS installed. Version 16+ is what I'm using currently.
> node -v
v16.x.x
📣 Tip: Need to manage different versions of NodeJS? Try using NVM.
Flow CLI
Ensure you have the Flow CLI installed too. At the time of this writing, I am using version 0.41.3.
> flow version
Version: v0.41.3
Initializing our project
Rather that have you copy and paste by hand a ton of stuff, I'm going to show you a pre-made app and explain how it works. Then we'll expand on it together!
With your favorite terminal, create and navigate to a new directory like /twitter3
. Then clone the starter repo:
git clone https://github.com/muttoni/twitter3.git .
Let's take a look at the project directory:
Folder Structure
You can structure your folders however you want, the app shows a common way to do it. Here they are explained order:
File Glossary
- 🆕
/cadence
: contains all Cadence related files, like contracts, transactions and scripts. It contains respective subfolders for each one. If you open the subfolders, you'll see the exact same contract, transaction and script we created in the Playground example in the last section, with only 1 small change: the imports. -
/components
: this is standard NextJS folder to store React components - 🆕
/constants
: just a folder to save some handy constants - 🆕
/flow
: a folder dedicated to Flow-specific stuff. Currently this folder only contains a config file for the Flow Client Library, but in the future we could move Flow-logic to this folder. -
/hooks
: standard NextJS Hooks folder -
/pages
: standard NextJS Pages folder -
/public
: standard NextJS public assets folder -
/styles
: standard NextJS styles folder - 🆕
emulatore.private.json
: a config snippet containing sensitive information, which we don't want added to the public flow.json. - 🆕
flow.json
: the standard Flow config file that instructs the Flow CLI what contracts to deploy - ...standard NextJS files
If you've used NextJS in the past, this folder structure looks very familiar, and only the 🆕 folders should be surprising.
Installing dependencies
Next let's install dependencies and run our app in dev!
npm install
It's alive!
Make sure to have 3 terminal windows open:
- App terminal
- Flow Emulator terminal
- Flow Dev Wallet terminal
In terminal 2, run
flow emulator start
In terminal 3, run
flow dev-wallet
Now in terminal 1, let's start the app!
npm run dev:local:deploy
If you're curious as to what the command above does, check the package.json
. In short, it deploys/updates the contracts to the Flow Emulator as defined in our flow.json
, which is the standard Flow CLI config file, and then runs npm run dev
for our NextJS app.
If you ran everything correctly you should see:
Click on "Log In With Wallet"
You should see popup window like the following:
This is the FCL discovery service, that exposes available wallets a user may want have/want to use. What's great about FCL is that the wallet is completely abstracted from the app, and the app is completely abstracted from the wallet, so any user can use any wallet and the code and app don't need to care (usually).
Since we're on the Emulator and not on Testnet or Mainnet yet, you'll want to select Dev Wallet.
You'll then be presented with a Dev Wallet splash screen, where you can select which account to use:
This is because in the Dev Wallet you can easily create and switch between different accounts, and quickly fund accounts with imaginary FLOW. The Service account is the "base" account on Flow, and you can create as many other "normal" accounts as you want. Click on Service Account or Account A.
Alright, now you're authenticated and the UI should reflect that.
Feel free to play around with the app first, then we'll start dissecting how it works.
Understanding the Flow
Before we look at the app code, I want to show you how to use the Flow CLI by running the exact same transaction and script we ran in our Playground example, only this time "for real" on the emulator.
As mentioned in the glossary, the /cadence
folder contains all Cadence related files, like our Twitter contract, and the transaction and script we'll need. It contains respective subfolders for each type.
If you open the subfolders, you'll see the exact same contract, transaction and script we created in the Playground example in the last section, with only 1 small change: the imports. You'll notice the transaction and script doesn't import 0x01
anymore, but a longer address. This is the address on the emulator that we are deploying the contracts to. You'll see how in just a second!
Let's imagine we don't have an app yet, and we just have these three folders setup. How would we interact with the emulator? The answer is the Flow CLI!
Deploying a contract to the emulator
In our app, the npm run dev:local:deploy
command does this automatically, but the underlying command it uses to deploy the contracts (or update them if they've changed) is just as simple:
flow project deploy
How does it know what to deploy? The answer is in the flow.json
Flow Configuration - flow.json
How does the Flow CLI know what contract to use and what account to find it in? The answer is the flow.json
- a project-specific configuration file. Here's what ours looks like:
{
"networks": {
"emulator": "127.0.0.1:3569",
"mainnet": "access.mainnet.nodes.onflow.org:9000",
"testnet": "access.devnet.nodes.onflow.org:9000"
},
"contracts": {
"Twitter": {
"source": "./cadence/contracts/Twitter.cdc",
"aliases": {
"emulator": "0xf8d6e0586b0a20c7"
}
}
},
"accounts": {
"emulator-account": {
"fromFile": "./emulator.private.json"
}
},
"deployments": {
"emulator": {
"emulator-account": ["Twitter"]
}
}
}
This configuration outlines what network addresses the CLI should use, what contracts are included in our application and therefore, should be deployed, and to which address.
📖 Read more about the flow.json here
Sending a transaction with the CLI
🚨 Check your transaction and scripts in this section and makes ure they are importing the Twitter contract from
0xf8d6e0586b0a20c7
and not0xTwitter
. The latter is specific to FCL which we will look at later.
Let's start by creating a Tweet. For this we'll use the Flow CLI's flow transaction send
command. As a first argument it expects the path or string of the transaction, and any arguments after that will be passed as arguments to the transaction. With the command below we're creating a tweet with text "Hello" using the default account and network (emulator) as defined in our flow.json
Try running this in your terminal (with the emulator running):
flow transactions send ./cadence/transactions/CreateNewTweet.cdc Hello
You should see an output similar to this:
You'll notice it used the account f8d6e0586b0a20c7
which is the main emulator service account we saw earlier (i.e. the service account). Fun fact, the 0x
is completely optional on Flow.
The status is shown as ✅ SEALED, which means the transaction has been fully confirmed and it is committed to the blockchain. There are transaction codes for different intermediate phases (and we'll see how to use them to provide transaction feedback when we deploy the app to testnet).
Code | Value | Description |
---|---|---|
0 | UNKNOWN | The transaction status is not known. |
1 | PENDING | The transaction has been received by a collector but not yet finalized in a block. |
2 | FINALIZED | The consensus nodes have finalized the block that the transaction is included in |
3 | EXECUTED | The execution nodes have produced a result for the transaction |
4 | SEALED | The verification nodes have verified the transaction (the block in which the transaction is) and the seal is included in the latest block |
5 | EXPIRED | The transaction was submitted past its expiration block height. |
📖 The
flow transactions send
command accepts other flags, such as what account to sign it as. See here for more info
Executing a script with the CLI
🚨 Check your transaction and scripts in this section and makes ure they are importing the Twitter contract from
0xf8d6e0586b0a20c7
and not0xTwitter
. The latter is specific to FCL which we will look at later.
To query an account's Tweets, we can use flow scripts execute
, which is structured very similarly to the transaction command: first argument is the script to execute, and any subsequent arguments will be passed to the script.
flow scripts execute ./cadence/scripts/GetTweetsByAccount.cdc f8d6e0586b0a20c7
Let's run it:
📖 The
flow scripts execute
command accepts several flags. See here for more info
Flow configuration - config.js
The other important Flow configuration is within the app, and configures the behavior of the Flow Client Library.
You'll find this in /flow/config.js
. This contains standard info like network, access node, as well as more app specific configuration data, like app name and icon.
FCL
Alright, so you now know how to interact with the contracts, transactions and scripts via the CLI. Let's start taking a look at how these are connected to the web app via the Flow Client Library (FCL).
Navigate to /flow/config.js
. You'll see a configuration file.
import { config } from '@onflow/fcl'
import { ACCESS_NODE_URLS } from '../constants'
import flowJSON from '../flow.json'
const flowNetwork = process.env.NEXT_PUBLIC_FLOW_NETWORK
console.log('Dapp running on network:', flowNetwork)
config({
'flow.network': flowNetwork,
'accessNode.api': ACCESS_NODE_URLS[flowNetwork],
'discovery.wallet': `https://fcl-discovery.onflow.org/${flowNetwork}/authn`,
'app.detail.icon': 'https://avatars.githubusercontent.com/u/50278?s=200&v=4',
'app.detail.title': 'Twitter3'
}).load({ flowJSON })
In this file we are telling FCL to:
flow.network
flow.network
is used to specific network (emulator, testnet, mainnet), as defined in the flowNetwork
variable populated by the .env file. In our case it's going to be the emulator. You'll notice we don't have a .env file anywhere in our project. That's because you have to look in the package.json. See below!
...
"scripts": {
"dev": "next dev",
"dev:local": "NEXT_PUBLIC_FLOW_NETWORK=local npm run dev",
"dev:local:deploy": "flow project deploy --network=emulator --update && NEXT_PUBLIC_FLOW_NETWORK=local npm run dev",
"dev:testnet": "NEXT_PUBLIC_FLOW_NETWORK=testnet npm run dev",
"dev:mainnet": "NEXT_PUBLIC_FLOW_NETWORK=mainnet npm run dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
...
accessNode.api
accessNode.api
is where we set what Access Node to connect to. This URL will change based on our Flow network. For the emulator, that is localhost:8888
. You can see the various API urls defined in constants/index.js
:
export const ACCESS_NODE_URLS = {
'local': 'http://localhost:8888',
'testnet': 'https://rest-testnet.onflow.org',
'mainnet': 'https://rest-mainnet.onflow.org'
}
discovery.wallet
FCL Discovery is one of the most powerful features of FCL. It allows any wallet on Flow to self-announce itself, meaning it will show up automatically in the list of available wallets when a user wants to login to a Flow app, without developers needing to do a single thing! Isn't that nifty?
app.detail.icon
and app.detail.title
These are purely cosmetic (an icon for your app, and a title), and these attributes will be used to populate the wallet selection popup generated by FCL. In our case we're using the Twitter logo and the title Twitter3. It looks like this:
Hooks
React hooks are a way to set some state that can be used throughout the app. If you go to /hooks/useConfig.js
and /hooks/useCurrentUser.js
we'll see two important hooks that also are linked with FCL.
useConfig.js
In this hook, we are setting state to load the current Flow network. We use fcl.config.get
to get a specific setting that we set previously.
import * as fcl from '@onflow/fcl'
import { useEffect, useState } from 'react'
export default function useConfig() {
const [network, setNetwork] = useState()
useEffect(() => {
async function getConfig() {
const flowNetwork = await fcl.config.get('flow.network')
setNetwork(flowNetwork)
}
getConfig()
}, [])
return { network }
}
useCurrentUser.js
In this hook, we're creating a user
object and populating it based on the state of fcl.currentUser
. This is a function that can be subscribed to and returns the state of the authenticated (or not) user. This gives us a user object we can query and create conditional logic around throughout the app (e.g. showing a Connect Wallet button if not logged in for example).
import * as fcl from '@onflow/fcl'
import { useEffect, useState } from 'react'
export default function useCurrentUser() {
const [user, setUser] = useState({ loggedIn: null })
useEffect(() => {
fcl.currentUser.subscribe(setUser)
}, [])
return user
}
Here's what the fcl.currentUser
object contains. loggedIn
and addr
are usually the most important values:
fcl.currentUser object
Key | Value Type | Default | Description |
---|---|---|---|
addr |
Address | null |
The public address of the current user |
cid |
string | null |
Allows wallets to specify a content identifier for user metadata. |
expiresAt |
number | null |
Allows wallets to specify a time-frame for a valid session. |
f_type |
string | 'USER' |
A type identifier used internally by FCL. |
f_vsn |
string | '1.0.0' |
FCL protocol version. |
loggedIn |
boolean | null |
If the user is logged in. |
services |
[ServiceObject] | [] |
A list of trusted services that express ways of interacting with the current user's identity, including means to further discovery, authentication, authorization, or other kinds of interactions. |
📖 Read more about the currentUser object and FCL here.
Components & Pages
Now that we covered the basic foundations of FCL, let's look at how these are applied in the actual app by looking at its components and pages.
pages/index.js
This is the main entry point into the app. Here we load the Flow config and use the useCurrentUser
hook to conditionally render the main container if the user is logged in.
import Head from 'next/head'
import Navbar from '../components/Navbar'
import '../flow/config.js'
import Container from '../components/Container'
import useCurrentUser from '../hooks/useCurrentUser'
export default function Home() {
const { loggedIn } = useCurrentUser()
return (
<div>
<Head>
<title>Twitter 3</title>
<meta name="description" content="Twitter3 - thoughts onchain'ed" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="container">
<Navbar />
{loggedIn && <Container />}
</main>
</div>
)
}
components/Container.js
This is the other "big" one to look through. In this component we render most of the UI and more or less this is where everything comes together: FCL, the transactions, the scripts and our app functionality. This is the MOTHERLOAD!
import * as fcl from "@onflow/fcl"
import { useEffect, useState } from "react"
import GetTweetsByAccount from '../cadence/scripts/GetTweetsByAccount.cdc'
import CreateNewTweet from '../cadence/transactions/CreateNewTweet.cdc'
import styles from '../styles/Container.module.css'
import useConfig from "../hooks/useConfig"
import useCurrentUser from '../hooks/useCurrentUser'
import { BLOCK_EXPLORER_URLS } from "../constants"
import Tweet from './Tweet'
export default function Container() {
const [tweetList, setTweetList] = useState([])
const [tweetText, setTweetText] = useState('')
const [lastTransactionId, setLastTransactionId] = useState()
const [transactionStatus, setTransactionStatus] = useState('N/A')
const { network } = useConfig()
const user = useCurrentUser()
const isEmulator = network => network !== 'mainnet' && network !== 'testnet'
const isSealed = statusCode => statusCode === 4 // 4: 'SEALED'
useEffect(() => {
if (lastTransactionId) {
console.log('Last Transaction ID: ', lastTransactionId)
fcl.tx(lastTransactionId).subscribe(res => {
setTransactionStatus(res.statusString)
// Query for new chain string again if status is sealed
if (isSealed(res.status)) {
getTweets()
}
})
}
}, [lastTransactionId])
const getTweets = async (account) => {
account = user?.addr;
let res;
try {
res = await fcl.query({
cadence: GetTweetsByAccount,
args: (arg, t) => [arg(account, t.Address)]
})
} catch(e) {
res = []
}
console.log(res)
setTweetList(res.sort((a, b) => b.id - a.id))
}
const createTweet = async (event) => {
event.preventDefault()
if (!tweetText.length) {
throw new Error('Please add a new greeting string.')
}
const transactionId = await fcl.mutate({
cadence: CreateNewTweet,
args: (arg, t) => [arg(tweetText, t.String)]
})
setLastTransactionId(transactionId)
}
const openExplorerLink = (transactionId, network) => window.open(`${BLOCK_EXPLORER_URLS[network]}/transaction/${transactionId}`, '_blank')
return (
<div className={styles.container}>
<div>
<form onSubmit={createTweet}>
<label for="tweet">Create a new Tweet</label>
<textarea
id="tweetContents"
placeholder="I feel..."
value={tweetText}
onChange={e => setTweetText(e.target.value)}
name="tweetContents" required></textarea>
<small>Share your thoughts with the world.</small>
<input type="submit" value="Tweet" />
</form>
</div>
<hr />
<h3>Your Tweets
<button onClick={getTweets} className={styles.refresh}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
</button>
</h3>
{
tweetList.length > 0 ?
tweetList.map((tweet) => {
return <Tweet address={user?.addr} message={tweet.message} key={tweet.id} />
}) : 'Your tweets will show up here!'
}
</div>
)
}
Structure
At a high level, there are 3 interacting parts to this code:
- Imports: We load transactions and scripts as variables.
- States: We set states to keep track of data
-
Actions: We define functions like
getTweets
that run the imported code on-chain, then populate the states.
Imports
import GetTweetsByAccount from '../cadence/scripts/GetTweetsByAccount.cdc'
import CreateNewTweet from '../cadence/transactions/CreateNewTweet.cdc'
We are able to import Cadence (.cdc) files thanks to the loader defined in the webpack settings defined in /next.config.js
.
States
We create several states:
const [tweetList, setTweetList] = useState([])
const [tweetText, setTweetText] = useState('')
const [lastTransactionId, setLastTransactionId] = useState()
const [transactionStatus, setTransactionStatus] = useState('N/A')
-
tweetList
is used to keep track of the array of tweets on chain. -
tweetText
is used to keep track of the tweet text that we are creating and will then publish -
lastTransactionId
is used to keep track of the transaction ID so that we can link out to the chain explorers like Flowscan or Flow-View-Source. We don't really need this when using the emulator because transactions are instant and we get feedback in the console. -
transactionStatus
is what contains the state of the transaction. Remember our section around possible transaction states above? No? Well here it is again:
Code | Value | Description |
---|---|---|
0 | UNKNOWN | The transaction status is not known. |
1 | PENDING | The transaction has been received by a collector but not yet finalized in a block. |
2 | FINALIZED | The consensus nodes have finalized the block that the transaction is included in |
3 | EXECUTED | The execution nodes have produced a result for the transaction |
4 | SEALED | The verification nodes have verified the transaction (the block in which the transaction is) and the seal is included in the latest block |
5 | EXPIRED | The transaction was submitted past its expiration block height. |
Actions
Running a script via FCL
How do we get the list of tweets from an account? It's super simple. We just use fcl.query
and pass in the Cadence code we want to run, and the arguments. Here's the annotated source:
const getTweets = async (account) => {
// read addr from user object
account = user?.addr;
let res;
try {
// run fcl.query which runs a script,
// using the addr above as argument
res = await fcl.query({
cadence: GetTweetsByAccount,
args: (arg, t) => [arg(account, t.Address)]
})
} catch(e) {
res = []
}
// set the state as the resulting array, after sorting.
setTweetList(res.sort((a, b) => b.id - a.id))
}
Running a transaction via FCL
Similarly to the script, running a transaction via FCL is also incredibly straightforward. Rather than fcl.query
, we use fcl.mutate
. We the pass in the Cadence transaction code imported previously and the arguments, like we do in the script. FCL will take care of triggering an authorization popup when publishing a tweet.
const createTweet = async (event) => {
event.preventDefault()
if (!tweetText.length) {
throw new Error('Please add a new greeting string.')
}
const transactionId = await fcl.mutate({
cadence: CreateNewTweet,
args: (arg, t) => [arg(tweetText, t.String)]
})
setLastTransactionId(transactionId)
}
The authorization popup looks like this:
Note: this layout is what the Dev Wallet shows, other wallets on Flow will show something similar, but it won't be exactly the same. The point is, there's an authorization step, and to mutate the chain you need to be authenticated as an account and sign the transaction. So any time you run a transaction, a popup will show up, asking the user to authorize the transaction.
Deploying to Testnet
Last thing to try is to deploy our wonderful app to testnet. This will give us a chance to see it in action on a real live chain.
Creating a testnet account
First things first, to deploy to testnet, given that it's a real live chain, we need a testnet account. To create one, all we need to do is run the following Flow CLI command:
flow accounts create
Here's what the process looks like:
1. Name your Account
Name your new account hero
and hit Enter. Follow the rest of the instructions on screen.
Enter an account name: hero
💡 You can pick any name, we are trying to keep the instructions in line with your experience. If you would decide to name your account differently, please use that name everywhere we refer to
hero
account and address.
2. Set your network to Flow Testnet
Scroll down once to select Flow Testnet, then hit Enter
Use the arrow keys to navigate: ↓ ↑ → ←
? Choose a network:
Local Emulator
▸ Flow Testnet
Flow Mainnet
3. Save Account Info
You'll then get presented with a confirmation step. Type y and hit Enter.
✔ Flow Testnet
❗ This command will perform the following:
- Generate a new ECDSA P-256 public and private key pair.
- Save the private key to hero.private.json and add it to .gitignore.
- Create a new account on Flow Testnet paired with the public key.
- Save the newly-created account to flow.json.
? Do you want to continue? [y/N] y
4. Fund your Testnet Account
Please complete the following steps in a web browser:
1. Complete the captcha challenge.
2. Click the 'Create Account' button.
3. Return to this window.
✔ Press <ENTER> to open in your browser...: █
Once you press Enter, your browser will be automatically directed to the Flow Testnet Faucet with your account information pre-populated.
The only actions that is required are:
Please complete the following steps in a web browser:
1. Complete the captcha challenge.
2. Click the 'Create Account' button.
3. Return to this window.
You can also navigate to the link manually: https://testnet-faucet.onflow.org/?key=<key_that_is_pre_populated>
Waiting for your account to be created, please finish all the steps in the browser...
5. You're all set!
🎉 New account created with address 0xebeb17c521a0d375 and name hero.
Here’s a summary of all the actions that were taken:
- Added the new account to flow.json.
- Saved the private key to hero.private.json.
- Added hero.private.json to .gitignore.
After you finish all the steps, you will notice that 2 new files are now present in the directory:
flow.json
-
hero.private.json
The Flow CLI automatically created a second file called hero.private.json
. This file contains our private key from our newly created testnet account. This file is automatically added to the .gitignore
so you don't accidentally leak any credentials!
If you inspect the files, you should see the address and private key for your freshly minted account 👍!
flow.json
You'll also notice the flow.json changed automatically, and now lists "hero" as an account, linking to the json.
...
"accounts": {
"emulator-account": {
"fromFile": "./emulator.private.json"
},
"hero": {
"fromFile": "hero.private.json"
}
},
...
We need to now create an account alias for the contract so FCL will know which account to substitute the imports with when on testnet on our transactions and scripts.
"contracts": {
"Twitter": {
"source": "./cadence/contracts/Twitter.cdc",
"aliases": {
"emulator": "0xf8d6e0586b0a20c7",
+ "testnet": "0x3c1f94baa070dd43"
}
}
},
And we also need to add a deployment specification for testnet in the flow.json:
...
"testnet": {
"hero": [
"Twitter"
]
}
Our new and improved flow.json looks like this:
{
"contracts": {
"Twitter": {
"source": "./cadence/contracts/Twitter.cdc",
"aliases": {
"emulator": "0xf8d6e0586b0a20c7",
+ "testnet": "0x3c1f94baa070dd43"
}
}
},
"networks": {
"emulator": "127.0.0.1:3569",
"mainnet": "access.mainnet.nodes.onflow.org:9000",
"testnet": "access.devnet.nodes.onflow.org:9000"
},
"accounts": {
"emulator-account": {
"fromFile": "./emulator.private.json"
},
+ "hero": {
+ "fromFile": "hero.private.json"
+ }
},
"deployments": {
"emulator": {
"emulator-account": [
"Twitter"
]
},
+ "testnet": {
+ "hero": [
+ "Twitter"
+ ]
+ }
}
}
Deploying
🚨 Check your transaction and scripts before starting this section and makes ure you change the imports from
import Twitter from 0xf8d6e0586b0a20c7
toimport Twitter from 0xTwitter
. The latter is specific to FCL and will allow seamlessly switching between the emulator and testnet/mainnet in our app. If you ever want to use the CLI again directly, you'll need to switch back.
Alright, let's get to deploying!
Let's run the same command we ran in the emulator, but specifying testnet as our network. The Flow CLI will read the configuration file (flow.json) we updated and know which contract to deploy.
flow project deploy --network=testnet
You should see:
Deploying 1 contracts for accounts: hero
Twitter deploying...⠴
It might take 30 seconds or so. Then you should see:
🎉 Yay!
Running app on testnet
🚨 Check your transaction and scripts before starting this section and makes ure you change the imports from
import Twitter from 0xf8d6e0586b0a20c7
toimport Twitter from 0xTwitter
. The latter is specific to FCL and will allow seamlessly switching between the emulator and testnet/mainnet in our app. If you ever want to use the CLI again directly, you'll need to switch back.
Now let's use the built-in npm run dev:testnet
, which will run the app and set the NEXT_PUBLIC_FLOW_NETWORK
to testnet
. Our app should update automagically!
npm run dev:testnet
✅ Remember, the npm commands are just conveniences, you can always look through the package.json to see what the commands do. You'll realize they are very thin wrappers around standard and simple Flow CLI commands + NextJS ones.
You should see the same login page as before, and if you click "Connect Wallet" you should now see:
These are actual, real, live, awesome, supercharged Flow wallets!
Let's pick Blocto. All you need is an email. Pretty sweet right? Wallets on Flow are super user-friendly.
👉 You'll also notice the
DEV
next to Blocto. This is because we are running on testnet, so the wallet is also on testnet. Just put in your email (or a temp one) and click Sign in / Register.
Check your email and put in the code and click Login!
We're live, on testnet!
Let's try writing a tweet. Then click Tweet.
A popup similar to the dev wallet will show up, asking you to approve the transaction.
You can also toggle the "Script" section and look at the code, similar to how we saw it in the Dev Wallet.
Once you click approve, you see nothing really happens, but if you check the console, you'll see a transaction ID:
We can use that to visit a blockchain explorer like Flowscan which is more user-friendly for non-devs or Flow View Source which is more "in the weeds".
Here's Flowscan:
Here's Flow View Source:
If everything worked correctly, you should now have a sweet, amazing, awesome tweet on Testnet!
Homework
Ok, now you have all the pieces. Now it's your turn to make this app better!
Better transaction feedback
For example, you see we have a transaction ID, and a transaction state. Try to create a simple transaction feedback component (or in the Container directly) to give people some feedback around what's happening with their transaction. Sometimes transactions can take 10-30 seconds, so it's important to give feedback.
Extending the contract
Currently, the only metadata we have is the actual contents of the tweet. Wouldn't it be cool if tweets had additional stuff like: date, number of likes, etc? Some additional metadata will require a pretty significant overhaul of the contract, incuding adding brand new transactions and scripts...are you up for it? :)
Feedback and suggestions
Have suggestions? Feedback? Leave an issue on Github
Feel free to follow me on (real) Twitter at @muttonia
You can also find me on the Flow Discord if you have any questions.
👋 and happy building!
Posted on December 2, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 2, 2022