Building Full Stack Permanent Applications with Arweave, Smartweave, and Next.js

Nader Dabit - Sep 10 '22 - - Dev Community

The code for this tutorial can be found here

In this tutorial you'll learn how to build full stack dapps on Arweave with Smarweave, Warp, and Next.js.

Smartweave TLDR

  • Code smart contracts in JS, TS, or Rust
  • Execute arbitrary amounts of compute without additional fees
  • Never have to worry about gas optimizations
  • No state bloat
  • Can directly process rich content / large files
  • Warp offers enhancements (speed, caching, sdks)

The application we'll be building is a full stack blog, meaning that you will have an open, public, and composable back end that can be transferred and reused anywhere (not only in this app).

Unlike most blockchain applications working with large or arbitrary amounts of data, Smartweave enables all of the state for this app to be stored directly on-chain.

I think this is a good example as it's not too basic as to be boring, but not too complex as to be confusing. It shows how to do most of the basic things you'll need and want to understand in order to build more complex and sophisticated applications going forward.

About Arweave

Arweave is a web3 protocol that allows developers to permanently store files like images, videos, and pdfs as well as single page web applications.

Arweave introduced the idea of the permaweb - a permanent, global, community-owned web that anyone can contribute to or get paid to maintain.

Smartweave

Arweave also introduced SmartWeave: a smart contract protocol that allows developers to build permanent applications on top of Arweave.

When you publish a Smartweave contract, the program’s source code and its initial state is stored in an Arweave transaction.

When a user writes an update to a SmartWeave program, they write their inputs as a new Arweave transaction.

To calculate the state of the contract, a SmartWeave client uses the contract source code to execute the history of inputs sequentially. Invalid transactions are ignored.

In doing so, SmartWeave pushes the responsibility of validating transactions onto the users.

Warp

Warp (https://warp.cc/) is a protocol built on top of Arweave meant to facilitate a better DX/UX for Smartweave application development.

Warp consists of 3 main layers:

  1. The Core Protocol layer is the implementation of the original SmartWeave protocol and is responsible for communication with the SmartWeave smart contracts deployed on Arweave

  2. The Caching layer - is build on top of the Core Protocol layer and allows caching results of each of the Core Protocol modules separately.

    This allows you to quickly retrieve data from contracts with a large number of state updates, but also offers instant transactions and contracts availability and finality.

  3. Extensions layer - a CLI, Debugging tools, different logging implementations, so called "dry-runs" (i.e. actions that allow to quickly verify the result of given contract interaction without writing anything on Arweave).

Getting started

Now that we know a little bit about the underlying technology, let's start building.

Prerequisites

To be successful in this tutorial, you should have Node.js 16.17.0 or greater installed on your machine.

I recommend using either nvm or fnm to manage Node.js versions.

Creating and configuring the project

To get started, let's first create the Next.js application, configure it, and install the dependencies.

npx create-next-app full-stack-arweave
Enter fullscreen mode Exit fullscreen mode

Change into the new directory and install the following dependencies:

npm install warp-contracts react-markdown uuid
Enter fullscreen mode Exit fullscreen mode

Configuring the Next.js app

Open package.json and add the following configuration:

"type": "module",
Enter fullscreen mode Exit fullscreen mode

Then update next.config.js to use ES Modules to export the nextConfig:

/* replace */
module.exports = nextConfig

/* with this*/
export default nextConfig
Enter fullscreen mode Exit fullscreen mode

This will enable the Next.js app to use ES Modules.

Next, add the following to the .gitignore file:

wallet.json
testwallet.json
transactionid.js
Enter fullscreen mode Exit fullscreen mode

Never push wallet information to any public place like GitHub. In this tutorial we'll only be working with testnet, but we'll have the code available for you to push to mainnet. Just in case, we're adding wallet.json to .gitignore.

Warp Contracts

Next, let's create and test out the smart contracts.

About Smartweave contracts

Smartweave contracts work like this.

1. An initial state for the application is defined as a JSON object.

Some basic initial state might look something like this for a counter application that increments and decrements a number:

{
"counter" : 0
}
Enter fullscreen mode Exit fullscreen mode

2. The logic of a Smartweave contract is written in a function named handle.

This function defines different actions that can be called on the contract, which manipulate the state. Actions are similar to functions in a normal smart contract or program. Each action will update the state in some way.

A basic handler for a counter that uses the above state might look something like this:

export function handle(state, action) {
  if (action.input.function === 'increment') {
    state.counter += 1
  }
  if (action.input.function === 'decrement') {
    state.counter -= 1
  }
  return { state }
}
Enter fullscreen mode Exit fullscreen mode

In this handler, there are two actions - increment or decrement. The logic here is pretty straightforward.

3. To update the state of the contract, we can call writeInteraction from the Warp SDK.

Here's a basic example of how this might look when calling this function on the server:

import { WarpFactory } from 'warp-contracts'
const transactionId = "BA3EIfkKvlPXLk5sEN8loAmp2zr0MezSPhwaujTNli8"
import wallet from './wallet.json'

let warp = WarpFactory.forLocal()
const contract = warp.contract(transactionId).connect(wallet)
await contract.writeInteraction({
  function: "decrement"
})
Enter fullscreen mode Exit fullscreen mode

Then, we can read the state at any time:

const contract = warp.contract(transactionId).connect();
const { cachedValue } = await contract.readState();
Enter fullscreen mode Exit fullscreen mode

Writing the contract

Now that we have a basic understanding of how contracts work, let's start writing some code.

In the root of the project, create a new folder named warp.

In this folder, create a new file named contract.js:

/* warp/contract.js */
export function handle(state, action) {
  /* address of the caller is available in action.caller */
  if (action.input.function === 'initialize') {
    state.author = action.caller
  }
  if (action.input.function === 'createPost' && action.caller === state.author) {
    const posts = state.posts
    posts[action.input.post.id] = action.input.post
    state.posts = posts
  }
  if (action.input.function === 'updatePost' && action.caller === state.author) {
    const posts = state.posts
    const postToUpdate = action.input.post
    posts[postToUpdate.id] = postToUpdate
    state.posts = posts
  }
  if (action.input.function === 'deletePost' && action.caller === state.author) {
    const posts = state.posts
    delete posts[action.input.post.id]
    state.posts = posts
  }
  return { state }
}
Enter fullscreen mode Exit fullscreen mode

This is the contract for our blogging app.

We have functions to create, update, and delete a post (CRUD). We also have an initialize function which adds a basic authorization rule that only allows the blog owner to call any of these functions by setting the contract deployer as the owner.

Next, create a file in the warp directory named state.json and add the following JSON:

{
  "posts": {},
  "author": null
}
Enter fullscreen mode Exit fullscreen mode

Here, we have an initial state of posts set to an empty object, and author set to null.

We're done with our contract, now let's write the code to deploy, update, and read the state of the contract.

Deploy, update, and read

Next, create a new file named configureWarpServer.js in the warp directory.

import { WarpFactory } from 'warp-contracts'
import fs from 'fs'

/*
*  environment can be 'local' | 'testnet' | 'mainnet' | 'custom';
*/

const environment = process.env.WARPENV || 'testnet'
let warp

if (environment === 'testnet') {
  warp = WarpFactory.forTestnet()
} else if (environment === 'mainnet') {
  warp = WarpFactory.forMainnet()
} else {
  throw Error('environment not set properly...')
}

async function configureWallet() {
  try {
    if (environment === 'testnet') {
      /* for testing, generate a temporary wallet */
      try {
        return JSON.parse(fs.readFileSync('../testwallet.json', 'utf-8'))
      } catch (err) {
        const { jwk } = await warp.generateWallet()
        fs.writeFileSync('../testwallet.json', JSON.stringify(jwk))
        return jwk
      }
    } else if (environment === 'mainnet') {
      /* for mainnet, retrieve a local wallet */
      return JSON.parse(fs.readFileSync('../wallet.json', 'utf-8'))
    } else {
      throw Error('Wallet not configured properly...')
    }
  } catch (err) {
    throw Error('Wallet not configured properly...', err)
  }
}

export {
  configureWallet,
  warp
}
Enter fullscreen mode Exit fullscreen mode

In this file we're configuring warp server based on whether we're in a testing or mainnet (production) environment.

We then have a function that configures the wallet we'll be using to deploy the contract. If we're testing, we can just spin up a test wallet automatically using generateWallet. If we're in production, we have the option of importing a wallet locally.

Now that we have the ability to configure a wallet and a Warp server, let's create the function for deploying the contracts.

Deploying the contract

Create a new file named deploy.js in the warp directory with the following code:

import fs from 'fs'
import { configureWallet, warp } from './configureWarpServer.js'

async function deploy() {
  const wallet = await configureWallet()
  const state = fs.readFileSync('state.json', 'utf-8')
  const contractsource = fs.readFileSync('contract.js', 'utf-8')

  const { contractTxId } = await warp.createContract.deploy({
    wallet,
    initState: state,
    src: contractsource
  })
  fs.writeFileSync('../transactionid.js', `export const transactionId = "${contractTxId}"`)

  const contract = warp.contract(contractTxId).connect(wallet)
  await contract.writeInteraction({
    function: 'initialize'
  })
  const { cachedValue } = await contract.readState()

  console.log('Contract state: ', cachedValue)
  console.log('contractTxId: ', contractTxId)
}

deploy()
Enter fullscreen mode Exit fullscreen mode

The deploy function will deploy the contract to Arweave and write the transaction id to the local file system.

Reading the state

Next, let's create a file named read.js with the following code:

import { warp, configureWallet } from './configureWarpServer.js'
import { transactionId } from '../transactionid.js'

async function read() {
  let wallet = await configureWallet()
  const contract = warp.contract(transactionId).connect(wallet);
  const { cachedValue } = await contract.readState();

  console.log('Contract state: ', JSON.stringify(cachedValue))
}
read()
Enter fullscreen mode Exit fullscreen mode

Writing an update

The last function we'll write is for creating a new post.

In the warp directory, create a new file named createPost.js with the following code:

import { warp, configureWallet } from './configureWarpServer.js'
import { transactionId } from '../transactionid.js'
import { v4 as uuid } from 'uuid'

async function createPost() {
  let wallet = await configureWallet()
  const contract = warp.contract(transactionId).connect(wallet)
  await contract.writeInteraction({
    function: "createPost",
    post: {
      title: "Hi from first post!",
      content: "This is my first post!",
      id: uuid()
    }
  })
}

createPost()
Enter fullscreen mode Exit fullscreen mode

Testing it out

Now we can test everything out.

To deploy the contract, run the following command from the warp directory:

node deploy
Enter fullscreen mode Exit fullscreen mode

This should deploy the contract to testnet.

Once the contract has been deployed, you can use the Sonar block explorer to view the contract and the current state of it. The contract transaction ID will be available in transactionid.js. Be sure to switch to testnet to view the contract from this deployment.

Sonar Transaction

Next, let's read the current state:

node read
Enter fullscreen mode Exit fullscreen mode

The returned state of the contract should look something like this:

{"state":{"posts":{},"author":"-YzqAM_VDCqFZEk6iZ3B8Y-b6SxHoh0F1SvjOCW49nY"},"validity":{"36CmMGSlrGNvvCCfldtiUza4ZnQ9_bFW0YoEh8NCVe0":true},"errorMessages":{}}
Enter fullscreen mode Exit fullscreen mode

Now, let's create a post:

node createPost
Enter fullscreen mode Exit fullscreen mode

Now, when we read the updated state, we should see the new post in the updated state:

node read
Enter fullscreen mode Exit fullscreen mode

Building out the web app

Now that we understand how to deploy and test out a Smartweave contract with Warp, let's build out a front-end application that will interact with it and use it.

Since the application we're building is a blog, we'll need to create two basic views:

  1. A view to see the posts created by the user.
  2. A view to allow users to create posts.

We'll also need a file to hold a function we'll be using to configure warp for the client (similar to how we configured warp for the server previously).

Create a new file named configureWarpClient.js in the root of the app and add the following code:

import { WarpFactory } from 'warp-contracts'
import { transactionId } from './transactionid'
import wallet from './testwallet'

/*
*  environment can be 'local' | 'testnet' | 'mainnet' | 'custom';
*/

const environment = process.env.NEXT_PUBLIC_WARPENV || 'testnet'
let warp
let contract

async function getContract() {
  if (environment == 'testnet') {
    warp = WarpFactory.forTestnet()
    contract = warp.contract(transactionId).connect(wallet)
  } else if (environment === 'mainnet') {
    warp = WarpFactory.forMainnet()
    contract = warp.contract(transactionId).connect()
  } else {
    throw new Error('Environment configured improperly...')
  }
  return contract
}

export {
  getContract
}
Enter fullscreen mode Exit fullscreen mode

Creating a post

Next, in the pages directory create a new file named create-post.js and add the following code:

import { useState } from 'react'
import { getContract } from '../configureWarpClient'
import { v4 as uuid } from 'uuid'
import { useRouter } from 'next/router'

export default function createPostComponent() {
  const [post, updatePost] = useState({
    title: '', content: ''
  })
  const router = useRouter()

  async function createPost() {
    if (!post.title || !post.content) return
    post.id = uuid()
    const contract = await getContract()
    try {
      const result = await contract.writeInteraction({
        function: "createPost",
        post
      })
      console.log('result:', result)
      router.push('/')
    } catch (err) {
      console.log('error:', err)
    }
  }
  return (
    <div style={formContainerStyle}>
      <input
        value={post.title}
        placeholder="Post title"
        onChange={e => updatePost({ ...post, title: e.target.value})}
        style={inputStyle}
      />
      <textarea
        value={post.content}
        placeholder="Post content"
        onChange={e => updatePost({ ...post, content: e.target.value})}
        style={textAreaStyle}
      />
      <button style={buttonStyle} onClick={createPost}>Create Post</button>
    </div>
  )
}

const formContainerStyle = {
  width: '900px',
  margin: '0 auto',
  display: 'flex',
  flexDirection: 'column',
  alignItems: 'flex-start'
}

const inputStyle = {
  width: '300px',
  padding: '8px',
  fontSize: '18px',
  border: 'none',
  outline: 'none',
  marginBottom: '20px'
}

const buttonStyle = {
  width: '200px',
  padding: '10px 0px'
}

const textAreaStyle = {
  width: '100%',
  height: '300px',
  marginBottom: '20px',
  padding: '20px'
}
Enter fullscreen mode Exit fullscreen mode

Reading and displaying posts

Next, update pages/index.js with the following code:

import { useEffect, useState } from 'react'
import { getContract } from '../configureWarpClient'
import ReactMarkdown from 'react-markdown'

export default function Home() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    readState()
  }, [])
  async function readState() {
    const contract = await getContract()
    try {
      const data = await contract.readState()
      console.log('data: ', data)
      const posts = Object.values(data.cachedValue.state.posts)
      setPosts(posts)
      console.log('posts: ', posts)
    } catch (err) {
      console.log('error: ', err)
    }
  }

  return (
    <div style={containerStyle}>
      <h1 style={headingStyle}>PermaBlog</h1>
      {
        posts.map((post, index) => (
          <div key={index} style={postStyle}>
            <p style={titleStyle}>{post.title}</p>
            <ReactMarkdown>
              {post.content}
            </ReactMarkdown>
          </div>
        ))
      }
    </div>
  )
}

const containerStyle = {
  width: '900px',
  margin: '0 auto'
}

const headingStyle = {
  fontSize: '64px'
}
const postStyle = {
  padding: '15px 0px 0px',
  borderBottom: '1px solid rgba(255, 255, 255, .2)'
}

const titleStyle = {
  fontSize: '34px',
  marginBottom: '0px'
}
Enter fullscreen mode Exit fullscreen mode

Navigation

Next, update pages/_app.js

import '../styles/globals.css'
import Link from 'next/link'

function MyApp({ Component, pageProps }) {
  return (
    <div>
      <nav style={navStyle}>
        <Link href="/" style={linkStyle}>
          Home
        </Link>
        <Link href="/create-post" style={linkStyle}>
          Create Post
        </Link>
      </nav>
      <Component {...pageProps} />
    </div>
  )
}

const navStyle = {
  padding: '30px 100px'
}

const linkStyle = {
  marginRight: '30px'
}

export default MyApp
Enter fullscreen mode Exit fullscreen mode

Testing it out

Now let's run the app and test it out:

npm run dev
Enter fullscreen mode Exit fullscreen mode

When the app loads, the post created on the server should be rendered in the UI.

Next, create a post. If the post is successfully created, it should show up in the list of posts on the home page.

Next Steps

Deploying to mainnet

If you'd like to deploy and connect to Arweave mainnet, follow these steps:

  1. Download ArConnect wallet

  2. Request AR tokens from faucet, purchase them from an exchange, or swap at an exchange like changeNOW

  3. Download new wallet into a file named wallet.json. Be sure to add this file to your .gitignore and never ever make public or push to Git.

  4. Set local environment variable to mainnet in the terminal session you will be deploying from :

    export WARPENV=mainnet
    
  5. Create .env.local file in the root of the app and add the following environment variable:

    NEXT_PUBLIC_WARPENV=mainnet
    
  6. Deploy the contract from the warp directory:

    node deploy
    
  7. Run the app

    npm run dev
    

Learning resources

If you'd like to dive deeper and learn more about Warp, Smartweave, and Arweave, check out Warp Academy

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .