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:
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
-
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.
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
Change into the new directory and install the following dependencies:
npm install warp-contracts react-markdown uuid
Configuring the Next.js app
Open package.json
and add the following configuration:
"type": "module",
Then update next.config.js
to use ES Modules to export the nextConfig
:
/* replace */
module.exports = nextConfig
/* with this*/
export default nextConfig
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
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 tomainnet
. Just in case, we're addingwallet.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
}
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 }
}
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"
})
Then, we can read the state at any time:
const contract = warp.contract(transactionId).connect();
const { cachedValue } = await contract.readState();
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 }
}
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
}
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
}
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()
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()
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()
Testing it out
Now we can test everything out.
To deploy the contract, run the following command from the warp
directory:
node deploy
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.
Next, let's read the current state:
node read
The returned state of the contract should look something like this:
{"state":{"posts":{},"author":"-YzqAM_VDCqFZEk6iZ3B8Y-b6SxHoh0F1SvjOCW49nY"},"validity":{"36CmMGSlrGNvvCCfldtiUza4ZnQ9_bFW0YoEh8NCVe0":true},"errorMessages":{}}
Now, let's create a post:
node createPost
Now, when we read the updated state, we should see the new post in the updated state:
node read
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:
- A view to see the posts created by the user.
- 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
}
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'
}
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'
}
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
Testing it out
Now let's run the app and test it out:
npm run dev
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:
Download ArConnect wallet
Request AR tokens from faucet, purchase them from an exchange, or swap at an exchange like changeNOW
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.-
Set local environment variable to
mainnet
in the terminal session you will be deploying from :
export WARPENV=mainnet
-
Create
.env.local
file in the root of the app and add the following environment variable:
NEXT_PUBLIC_WARPENV=mainnet
-
Deploy the contract from the
warp
directory:
node deploy
-
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