DEV Community

Grant F.
Grant F.

Posted on • Updated on

Building a blog on Solana with Anchor

Prerequisites

This tutorial assumes you already have some basic knowledge of working with Anchor. There are a few good guides out there to help you get started with this:

These tutorials cover everything you'll need to know to get started so I won't cover what they already have. Please refer to these for guides for a basic intro and to help get setup with Anchor - with this tutorial I'm simply building on the shoulders of these fine sers.

You can find the source code for this tutorial here.

A blog on a blockchain?

The idea to build an on-chain "Blog" (note the inverted commas) came from a livestream interview with Armani Ferrante where he explained that maximum account space on Solana is 10MB and gave the example of a blog as something that could hypothetically be built on the Solana blockchain. I thought this would be an interesting learning exercise as it would require understanding how to:

  1. Create a program that can control multiple related accounts via Program Derived Addresses.
  2. Implement constraints so that only the author of the blog can create new posts for a certain PDA.

Please note that this really is just a learning exercise and it's not intended to be deployed to Mainnet - especially given the fact that keeping an account alive for a single ~10kb post will cost a decent amount in rent (~$10 depending on the current price of SOL).

Solana is not really built for this kind of storage (at least not currently). There are certainly more cost efficient ways to build an on-chain blog which I'll aim to cover in future posts.

The program

1. Initializing a blog

Let's get started with our program. Once you've bootstrapped your new Anchor project with $ anchor init anchor-blog, open the programs/anchor-blog/src/lib.rs and add the following code to the bottom of this file in order to define our Blog account struct:

#[account]
#[derive(Default)]
pub struct Blog {
    pub bump: u8,
    pub post_count: u8,
    pub authority: Pubkey,
}
Enter fullscreen mode Exit fullscreen mode

Here we define a counter post_count property which will record the number posts in the blog and an authority property which will define who can create new posts for this blog.

If you have read Brian Friel's post on Program Derived Addresses, you'll know that the bump property on this account indicates that it will be a Program Derived Address - that is the account will be owned by the executing anchor_blog program rather than by a public/private keypair.

Next, let's define our instructions and method for our blog initialization and then I will explain how this will work. First update the Initialize instruction with the following:

#[derive(Accounts)]
#[instruction(blog_account_bump: u8)]
pub struct Initialize<'info> {
    #[account(
        init,
        seeds = [
            b"blog_v0".as_ref(),
            user.key().as_ref(),
        ],
        bump = blog_account_bump,
        payer = user
    )]
    blog_account: Account<'info, Blog>,
    #[account(mut)]
    user: Signer<'info>,
    system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

Our blog account will have a PDA derived from the seeds "blog_v0" and the signing user's public address. Importantly, this means that each user will have a unique PDA for their blog account.

Next, update our program's initialize method with the following:

pub fn initialize(ctx: Context<Initialize>, blog_account_bump: u8) -> ProgramResult {
    ctx.accounts.blog_account.bump = blog_account_bump;
    ctx.accounts.blog_account.authority = *ctx.accounts.user.to_account_info().key;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The key here is that we are assigning the user key as the authority. In future, only the authority will be able to create posts for this blog.

Also note the asterix * in this assignment. For users new to Rust, this is a dereference operator. It simply ensures that we are using the value of the key here rather than the pointer.

Now that we have written part of our program, lets write some tests to ensure our blog will initialize correctly.

In order to be sure we can test different scenarios, we are going to add some helper methods. Create a tests/helpers.ts file and add the following:

import * as anchor from "@project-serum/anchor";
import { AnchorBlog } from "../target/types/anchor_blog";

export function getProgram(
  provider: anchor.Provider
): anchor.Program<AnchorBlog> {
  const idl = require("../target/idl/anchor_blog.json");
  const programID = new anchor.web3.PublicKey(idl.metadata.address);
  return new anchor.Program(idl, programID, provider);
}

export function getProvider(
  connection: anchor.web3.Connection,
  keypair: anchor.web3.Keypair
): anchor.Provider {
  // @ts-expect-error
  const wallet = new anchor.Wallet(keypair);
  return new anchor.Provider(
    connection,
    wallet,
    anchor.Provider.defaultOptions()
  );
}

export async function requestAirdrop(
  connection: anchor.web3.Connection,
  publicKey: anchor.web3.PublicKey
): Promise<void> {
  const airdropSignature = await connection.requestAirdrop(
    publicKey,
    anchor.web3.LAMPORTS_PER_SOL * 20
  );
  await connection.confirmTransaction(airdropSignature);
}

Enter fullscreen mode Exit fullscreen mode

Next replace the boilerplate code in tests/anchor-blog.ts with the following:

import assert from "assert";
import * as anchor from "@project-serum/anchor";
import * as helpers from "./helpers";

describe("anchor-blog", async () => {
  // Configure the client to use the local cluster.
  const connection = new anchor.web3.Connection(
    "http://localhost:8899",
    anchor.Provider.defaultOptions().preflightCommitment
  );

  const provider = helpers.getProvider(
    connection,
    anchor.web3.Keypair.generate()
  );
  const program = helpers.getProgram(provider);

  const [blogAccount, blogAccountBump] =
    await anchor.web3.PublicKey.findProgramAddress(
      [Buffer.from("blog_v0"), provider.wallet.publicKey.toBuffer()],
      program.programId
    );

  before(async () => {
    await helpers.requestAirdrop(connection, provider.wallet.publicKey);
  });

  it("Initializes with 0 entries", async () => {
    await program.rpc.initialize(blogAccountBump, {
      accounts: {
        blogAccount,
        user: provider.wallet.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      },
    });

    const blogState = await program.account.blog.fetch(blogAccount);

    assert.equal(0, blogState.postCount);
  });
});

Enter fullscreen mode Exit fullscreen mode

Now that we are ready to run a test, build your project:

$ anchor build
Enter fullscreen mode Exit fullscreen mode

Make sure that your program ID is updated and run:

$ anchor test
Enter fullscreen mode Exit fullscreen mode

2. Creating a post

Now that we can initialize a blog, let's implement our method for creating a post. We will start by defining our Post account struct in the programs/anchor-blog/src/lib.rs file:

#[account]
#[derive(Default)]
pub struct Post {
    pub authority: Pubkey,
    pub bump: u8,
    pub entry: u8,
    pub title: String,
    pub body: String,
}
Enter fullscreen mode Exit fullscreen mode

We are going to keep this simple but feel free to improvise and add more fields if you are feeling adventurous. Each Post account will have a title, body and an entry number.

Next let's define our instructions for the create_post RPC method:

#[derive(Accounts)]
#[instruction(post_account_bump: u8, title: String, body: String)]
pub struct CreatePost<'info> {
    #[account(mut, has_one = authority)]
    pub blog_account: Account<'info, Blog>,
    #[account(
        init,
        seeds = [
            b"post".as_ref(),
            blog_account.key().as_ref(),
            &[blog_account.post_count as u8].as_ref()
        ],
        bump = post_account_bump,
        payer = authority,
        space = 10000
    )]
    pub post_account: Account<'info, Post>,
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>
}
Enter fullscreen mode Exit fullscreen mode

As defined by the seeds array, each Post account will have a PDA derived from the Blog account's public key (which is also a PDA) and the Blog account's post_count value.

We assign 10kb to space which is an arbitary value here and simply ensures that we will have enough space for a hypthetically sizeable blog article.

Note that a Blog account must already have been initialized to create a post and must be provided here. We also add a constraint has_one = authority to require that the Blog account's authority signs this instruction. This will ensure that:

CreatePost.blog_account.authority == CreatePost.authority.key
Enter fullscreen mode Exit fullscreen mode

Finally, let's define our create_post RPC method:

pub fn create_post(ctx: Context<CreatePost>, post_account_bump: u8, title: String, body: String) -> ProgramResult {
    ctx.accounts.post_account.bump = post_account_bump;
    ctx.accounts.post_account.authority = *ctx.accounts.authority.to_account_info().key;
    ctx.accounts.post_account.title = title;
    ctx.accounts.post_account.body = body;
    ctx.accounts.post_account.entry = ctx.accounts.blog_account.post_count;
    ctx.accounts.blog_account.post_count += 1;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This should be more or less self-explanatory. I'll simply point out here that we are also updating the blog_account by assigning the current post_count value to be this Post's entry value, before also incrementing the Blog's post_count by one with ctx.accounts.blog_account.post_count += 1;.

Let's now add another test to tests/anchor-blog.ts to see if our create_post method is working. First, get the PDA address and bump in the describe block next to where we previosuly retrived our Blog account PDA:

const [firstPostAccount, firstPostAccountBump] =
  await anchor.web3.PublicKey.findProgramAddress(
    [
      Buffer.from("post"),
      blogAccount.toBuffer(),
      new anchor.BN(0).toArrayLike(Buffer),
    ],
    program.programId
  );
Enter fullscreen mode Exit fullscreen mode

And then add the following test:

it("Creates a post and increments the post count", async () => {
  const title = "Hello World";
  const body = "gm, this is a test post";

  await program.rpc.createPost(firstPostAccountBump, title, body, {
    accounts: {
      blogAccount,
      postAccount: firstPostAccount,
      authority: provider.wallet.publicKey,
      systemProgram: anchor.web3.SystemProgram.programId,
    },
  });

  const blogState = await program.account.blog.fetch(blogAccount);
  const postState = await program.account.post.fetch(firstPostAccount);

  assert.equal(title, postState.title);
  assert.equal(body, postState.body);
  assert.equal(0, postState.entry);
  assert.equal(1, blogState.postCount);
});
Enter fullscreen mode Exit fullscreen mode

Rebuild your project with $ anchor build and run anchor test (you may need to check the program ID has not changed but it will likely be the same).

We also want to be sure that only the Blog's authority can create a Post. Let's test that with the following:

it("Requires the correct signer to create a post", async () => {
  const title = "Hello World";
  const body = "gm, this is an unauthorized post";

  const [secondPostAccount, secondPostAccountBump] =
    await anchor.web3.PublicKey.findProgramAddress(
      [
        Buffer.from("post"),
        blogAccount.toBuffer(),
        new anchor.BN(1).toArrayLike(Buffer),
      ],
      program.programId
    );
  const newKeypair = anchor.web3.Keypair.generate();
  await helpers.requestAirdrop(connection, newKeypair.publicKey);
  const newProvider = helpers.getProvider(connection, newKeypair);
  const newProgram = helpers.getProgram(newProvider);

  let error;

  try {
    await newProgram.rpc.createPost(secondPostAccountBump, title, body, {
      accounts: {
        blogAccount,
        postAccount: secondPostAccount,
        authority: provider.wallet.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      },
    });
  } catch (err) {
    error = err;
  } finally {
    assert.equal(error.message, "Signature verification failed");
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Update a post

For our final method we want to be able to update a post. First, define our instructions:

#[derive(Accounts)]
#[instruction(tite: String, body: String)]
pub struct UpdatePost<'info> {
    #[account(mut, has_one = authority)]
    pub blog_account: Account<'info, Blog>,
    #[account(mut, has_one = authority)]
    pub post_account: Account<'info, Post>,
    pub authority: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

This method will use the same has_one = authority constraint as the create_post method, but because our post_account already exists our instructions are a little simpler this time.

Now we can add our update_post method:

pub fn update_post(ctx: Context<UpdatePost>, title: String, body: String) -> ProgramResult {
    ctx.accounts.post_account.title = title;
    ctx.accounts.post_account.body = body;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

And add some tests to make sure it works:

it("Updates a post", async () => {
  const title = "Hello World Update";
  const body = "gm, this post has been updated";

  await program.rpc.updatePost(title, body, {
    accounts: {
      blogAccount,
      postAccount: firstPostAccount,
      authority: provider.wallet.publicKey,
    },
  });

  const blogState = await program.account.blog.fetch(blogAccount);
  const postState = await program.account.post.fetch(firstPostAccount);

  assert.equal(1, blogState.postCount);
  assert.equal(title, postState.title);
  assert.equal(body, postState.body);
});

it("Requires the correct signer to update a post", async () => {
  const title = "Hello World Update";
  const body = "gm, this post has been updated";

  const newKeypair = anchor.web3.Keypair.generate();
  await helpers.requestAirdrop(connection, newKeypair.publicKey);
  const newProvider = helpers.getProvider(connection, newKeypair);
  const newProgram = helpers.getProgram(newProvider);

  let error;

  try {
    await newProgram.rpc.updatePost(title, body, {
      accounts: {
        blogAccount,
        postAccount: firstPostAccount,
        authority: provider.wallet.publicKey,
      },
    });
  } catch (err) {
    error = err;
  } finally {
    assert.equal(error?.message, "Signature verification failed");
  }
});
Enter fullscreen mode Exit fullscreen mode

And that's it! Remember to rebuild your project. If you want to see a very bare-bones example of how to create and load posts you can check out the basic app example here: https://github.com/Findiglay/anchor-blog/tree/main/app.

Top comments (0)