Create a Basic Program, Part 3 - Basic Security and Validation

Summary #

  • Thinking like an attacker is about shifting your mindset to proactively identify potential security gaps by asking, "How do I break this?"
  • Owner checks ensure that an account is controlled by the expected public key, such as verifying that a PDA (Program Derived Address) is owned by the program.
  • Signer checks confirm that the right parties have signed the transaction, allowing for safe modifications to accounts.
  • Account validation is used to ensure that the accounts passed into your program match your expectations, like checking the correctness of a PDA's derivation.
  • Data validation verifies that the instruction data provided to your program adheres to specific rules or constraints, ensuring it doesn't lead to unintended behavior.

Lesson #

In the previous lessons deserialize instruction data and program state management, we built a Movie Review program, and while getting it to function was exciting, secure development doesn't stop at "just working." It's critical to understand potential failure points and take proactive steps to secure your program against both accidental misuse and intentional exploitation.

Remember, you have no control over the transactions that will be sent to your program once it's deployed. You can only control how your program handles them. While this lesson is far from a comprehensive overview of program security, we'll cover some of the basic pitfalls to look out for.

Think Like an Attacker #

A fundamental principle in secure programming is adopting an "attacker's mindset." This means considering every possible angle someone might use to break or exploit your program.

In their presentation at Breakpoint 2021, Neodyme emphasized that secure program development isn't just about identifying when something is broken; it's about exploring how it can be broken. By asking, "How do I break this?" you shift from simply testing expected functionality to uncovering potential weaknesses in the implementation itself.

All programs, regardless of complexity, can be exploited. The goal isn't to achieve absolute security (which is impossible) but to make it as difficult as possible for malicious actors to exploit weaknesses. By adopting this mindset, you're better prepared to identify and close gaps in your program's security.

All Programs Can Be Broken #

Every program has vulnerabilities. The question isn't whether it can be broken, but how much effort it takes. As developers, our goal is to close as many security gaps as possible and increase the effort required to break our code. For example, while our Movie Review program creates accounts to store reviews, there may be unintentional behaviors that could be caught by thinking like an attacker. In this lesson, we'll explore these issues and how to address them.

Error handling #

Before we dive into some of the common security pitfalls and how to avoid them, it's important to know how to use errors in your program. Security issues in a Solana program often requires terminating the execution with a meaningful error. Not all errors are catastrophic, but some should result in stopping the program and returning an appropriate error code to prevent further processing.

Creating Custom Errors #

Solana's solana_program crate provides a generic ProgramError enum for error handling. However, custom errors allow you to provide more detailed, context-specific information that helps during debugging and testing.

We can define our own errors by creating an enum type listing the errors we want to use. For example, the NoteError contains variants Forbidden and InvalidLength. The enum is made into a Rust Error type by using the derive attribute macro to implement the Error trait from the thiserror library. Each error type also has its own #[error("...")] notation. This lets you provide an error message for each particular error type.

Here's an example of how you can define custom errors in your program:

use solana_program::program_error::ProgramError;
use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum NoteError {
    #[error("Unauthorized access - You don't own this note.")]
    Forbidden,
 
    #[error("Invalid note length - The text exceeds the allowed limit.")]
    InvalidLength,
}

In this example, we create custom errors for unauthorized access and invalid data input (such as note length). Defining custom errors gives us greater flexibility when debugging or explaining what went wrong during execution.

Returning Errors #

The compiler expects errors returned by the program to be of type ProgramError from the solana_program crate. That means we won't be able to return our custom error unless we have a way to convert it into this type. The following implementation handles conversion between our custom error and the ProgramError type.

impl From<NoteError> for ProgramError {
    fn from(e: NoteError) -> Self {
        ProgramError::Custom(e as u32)
    }
}

To return the custom error from the program, simply use the into() method to convert the error into an instance of ProgramError.

if pda != *note_pda.key {
    return Err(NoteError::Forbidden.into());
}

This ensures the program gracefully handles errors and provides meaningful feedback when things go wrong.

Basic Security Checks #

To ensure your Solana program is resilient against common vulnerabilities, you should incorporate key security checks. These are critical for detecting invalid accounts or unauthorized transactions and preventing undesired behavior.

Ownership checks #

An ownership check verifies that an account is owned by the expected program. For instance, if your program relies on PDAs (Program Derived Addresses), you want to ensure that those PDAs are controlled by your program and not by an external party.

Let's use the note-taking app example that we've referenced in the deserialize instruction data and program state management lessons. In this app, users can create, update, and delete notes that are stored by the program in PDA accounts.

When a user invokes the update instruction handler, they also provide a pda_account. We presume the provided pda_account is for the particular note they want to update, but the user can input any instruction data they want. They could even potentially send data that matches the data format of a note account but was not also created by the note-taking program. This security vulnerability is one potential way to introduce malicious code.

The simplest way to avoid this problem is to always check that the owner of an account is the public key you expect it to be. In this case, we expect the note account to be a PDA account owned by the program itself. When this is not the case, we can report it as an error accordingly.

Here's how you can perform an ownership check to verify that an account is owned by the program:

if note_pda.owner != program_id {
    return Err(ProgramError::InvalidNoteAccount);
}

In this example, we check if the note_pda is owned by the program itself (denoted by program_id). Ownership checks like these prevent unauthorized entities from tampering with critical accounts.

Info

PDAs are often considered to be trusted stores of a program's state. Ensuring the correct program owns the PDAs is a fundamental way to prevent malicious behavior.

Signer Checks #

Signer checks confirm that a transaction has been signed by the correct parties. In the note-taking app, for example, we want to verify that only the note creator can update the note. Without this check, anyone could attempt to modify another user's note by passing in their public key.

if !initializer.is_signer {
    msg!("Missing required signature");
    return Err(ProgramError::MissingRequiredSignature)
}

By verifying that the initializer has signed the transaction, we ensure that only the legitimate owner of the account can perform actions on it.

Account Validation #

Account validation checks that the accounts passed into the program are correct and valid. This is often done by deriving the expected account using known seeds (for PDAs) and comparing it to the passed account.

For instance, in the note-taking app, you can derive the expected PDA using the creator's public key and note ID, and then validate that it matches the provided account:

let (expected_pda, bump_seed) = Pubkey::find_program_address(
    &[
        note_creator.key.as_ref(),
        id.as_bytes().as_ref(),
    ],
    program_id
);
 
if expected_pda != *note_pda.key {
    msg!("Invalid seeds for PDA");
    return Err(ProgramError::InvalidArgument)
}

This check prevents a user from accidentally (or maliciously) passing the wrong PDA or one that belongs to someone else. By validating the PDA's derivation, you ensure the program is acting on the correct account.

Data Validation #

Data validation ensures that the input provided to your program meets the expected criteria. This is crucial for avoiding incorrect or malicious data that could cause the program to behave unpredictably.

For example, let's say your program allows users to allocate points to a character's attributes, but each attribute has a maximum allowed value. Before making any updates, you should check that the new allocation does not exceed the defined limit:

if character.agility + new_agility > 100 {
    msg!("Attribute points cannot exceed 100");
    return Err(AttributeError::TooHigh.into())
}

Similarly, you should check that the user is not exceeding their allowed number of points:

if attribute_allowance < new_agility {
    msg!("Trying to allocate more points than allowed");
    return Err(AttributeError::ExceedsAllowance.into())
}

Without these validations, the program could end up in an undefined state or be exploited by malicious actors, potentially causing financial loss or inconsistent behavior.

For example, imagine that the character referenced in these examples is an NFT. Further, imagine that the program allows the NFT to be staked to earn token rewards proportional to the NFTs number of attribute points. Failure to implement these data validation checks would allow a bad actor to assign an obscenely high number of attribute points and quickly drain your treasury of all the rewards that were meant to be spread more evenly amongst a larger pool of stakers.

Integer overflow and underflow #

One of the common pitfalls when working with integers in Rust (and in Solana programs) is handling integer overflow and underflow. Rust integers have fixed sizes and can only hold values within a certain range. When a value exceeds that range, it wraps around, leading to unexpected results.

For example, with a u8 (which holds values between 0 and 255), adding 1 to 255 results in a value of 0 (overflow). To avoid this, you should use checked math functions like checked_add() and checked_sub():

To avoid integer overflow and underflow, either:

  1. Have logic in place that ensures overflow or underflow cannot happen or

  2. Use checked math like checked_add() instead of +

    let first_int: u8 = 5;
    let second_int: u8 = 255;
    let sum = first_int.checked_add(second_int)
     .ok_or(ProgramError::ArithmeticOverflow)?;

Lab #

In this lab, we will build upon the Movie Review program that allows users to store movie reviews in PDA accounts. If you haven't completed the previous lessons deserialize instruction data and program state management, don't worry—this guide is self-contained.

The Movie Review program lets users add and update reviews in PDA accounts. In previous lessons, we implemented basic functionality for adding reviews. Now, we'll add security checks and implement an update feature in a secure manner. We'll use Solana Playground to write, build, and deploy our program.

1. Get the starter code #

To begin, you can find the movie review starter code. If you've been following along with the Movie Review labs, you'll notice that we've refactored our program.

The refactored starter code is almost the same as what it was before. Since lib.rs was getting rather large and unwieldy, we've separated its code into 3 files: lib.rs, entrypoint.rs, and processor.rs. lib.rs now only registers the code's modules, entrypoint.rs only defines and sets the program's entrypoint, and processor.rs handles the program logic for processing instructions. We've also added an error.rs file where we'll be defining custom errors. The complete file structure is as follows:

  • lib.rs - register modules
  • entrypoint.rs - entry point to the program
  • instruction.rs - serialize and deserialize instruction data
  • processor.rs - program logic to process instructions
  • state.rs - serialize and deserialize state
  • error.rs - custom program errors

In addition to some changes to the file structure, we've updated a small amount of code that will let this lab be more focused on security without having you write unnecessary boilerplate.

Since we'll be allowing updates to movie reviews, we also changed account_len in the add_movie_review() function (now in processor.rs). Instead of calculating the size of the review and setting the account length to only as large as it needs to be, we're simply going to allocate 1000 bytes to each review account. This way, we don't have to worry about reallocating size or re-calculating rent when a user updates their movie review.

We went from this:

let account_len: usize = 1 + 1 + (4 + title.len()) + (4 + description.len());

To this:

let account_len: usize = 1000;

The realloc method allows you to dynamically change the size of your accounts. We will not be using this method for this lab, but it's something to be aware of.

Finally, we've also implemented some additional functionality for our MovieAccountState struct in state.rs using the impl keyword.

For our movie reviews, we want the ability to check whether an account has already been initialized. To do this, we create an is_initialized function that checks the is_initialized field on the MovieAccountState struct.

Sealed is Solana's version of Rust's Sized trait. This simply specifies that MovieAccountState has a known size and provides for some compiler optimizations.

state.rs
impl Sealed for MovieAccountState {}
 
impl IsInitialized for MovieAccountState {
    fn is_initialized(&self) -> bool {
        self.is_initialized
    }
}

Before moving on, make sure you have a solid grasp of the current state of the program. Look through the code and spend some time thinking through any spots that are confusing to you. It may be helpful to compare the starter code to the solution code from the previous lesson.

2. Custom Errors #

We'll define custom errors to handle cases like uninitialized accounts, invalid PDA matches, exceeding data limits, and invalid ratings (ratings must be between 1 and 5). These errors will be added to the error.rs file:

The starter code includes an empty error.rs file. Open that file and add errors for each of the above cases.

error.rs
use solana_program::{program_error::ProgramError};
use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum ReviewError{
    // Error 0
    #[error("Account not initialized yet")]
    UninitializedAccount,
    // Error 1
    #[error("PDA derived does not equal PDA passed in")]
    InvalidPDA,
    // Error 2
    #[error("Input data exceeds max length")]
    InvalidDataLength,
    // Error 3
    #[error("Rating greater than 5 or less than 1")]
    InvalidRating,
}
 
impl From<ReviewError> for ProgramError {
    fn from(e: ReviewError) -> Self {
        ProgramError::Custom(e as u32)
    }
}

Note that in addition to adding the error cases, we also added an implementation that lets us convert our error into a ProgramError type as needed.

After adding the errors, import ReviewError in processor.rs to use them.

processor.rs
use crate::error::ReviewError;

3. Add Security Checks to add_movie_review #

Now that we have errors to use, let's implement some security checks to our add_movie_review function.

Signer check #

The first thing we should do is ensure that the initializer of a review is also a signer on the transaction. This ensures that you can't submit movie reviews impersonating somebody else. We'll put this check right after iterating through the accounts.

processor.rs
let account_info_iter = &mut accounts.iter();
 
let initializer = next_account_info(account_info_iter)?;
let pda_account = next_account_info(account_info_iter)?;
let system_program = next_account_info(account_info_iter)?;
 
if !initializer.is_signer {
    msg!("Missing required signature");
    return Err(ProgramError::MissingRequiredSignature)
}

Account validation #

Next, let's make sure the pda_account passed in by the user is the pda we expect. Recall we derived the pda for a movie review using the initializer and title as seeds. Within our instruction, we'll derive the pda again and then check if it matches the pda_account. If the addresses do not match, we'll return our custom InvalidPDA error.

processor.rs
// Derive PDA and check that it matches client
let (pda, _bump_seed) = Pubkey::find_program_address(&[initializer.key.as_ref(), account_data.title.as_bytes().as_ref(),], program_id);
 
if pda != *pda_account.key {
    msg!("Invalid seeds for PDA");
    return Err(ReviewError::InvalidPDA.into())
}

Data validation #

Now let's perform some data validation.

We'll start by making sure rating falls within the 1 to 5 scale. If the rating provided by the user outside of this range, we'll return our custom InvalidRating error.

processor.rs
if rating > 5 || rating < 1 {
    msg!("Rating cannot be higher than 5");
    return Err(ReviewError::InvalidRating.into())
}

Next, let's check that the content of the review does not exceed the 1000 bytes we've allocated for the account. If the size exceeds 1000 bytes, we'll return our custom InvalidDataLength error.

processor.rs
let total_len: usize = 1 + 1 + (4 + title.len()) + (4 + description.len());
if total_len > 1000 {
    msg!("Data length is larger than 1000 bytes");
    return Err(ReviewError::InvalidDataLength.into())
}

Lastly, let's check if the account has already been initialized by calling the is_initialized function we implemented for our MovieAccountState. If the account already exists, then we will return an error.

processor.rs
if account_data.is_initialized() {
    msg!("Account already initialized");
    return Err(ProgramError::AccountAlreadyInitialized);
}

Altogether, the add_movie_review() function should look something like this:

processor.rs
pub fn add_movie_review(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    title: String,
    rating: u8,
    description: String
) -> ProgramResult {
    msg!("Adding movie review...");
    msg!("Title: {}", title);
    msg!("Rating: {}", rating);
    msg!("Description: {}", description);
 
    let account_info_iter = &mut accounts.iter();
 
    let initializer = next_account_info(account_info_iter)?;
    let pda_account = next_account_info(account_info_iter)?;
    let system_program = next_account_info(account_info_iter)?;
 
    if !initializer.is_signer {
        msg!("Missing required signature");
        return Err(ProgramError::MissingRequiredSignature)
    }
 
    let (pda, bump_seed) = Pubkey::find_program_address(&[initializer.key.as_ref(), title.as_bytes().as_ref(),], program_id);
    if pda != *pda_account.key {
        msg!("Invalid seeds for PDA");
        return Err(ProgramError::InvalidArgument)
    }
 
    if rating > 5 || rating < 1 {
        msg!("Rating cannot be higher than 5");
        return Err(ReviewError::InvalidRating.into())
    }
 
    let total_len: usize = 1 + 1 + (4 + title.len()) + (4 + description.len());
    if total_len > 1000 {
        msg!("Data length is larger than 1000 bytes");
        return Err(ReviewError::InvalidDataLength.into())
    }
 
    let account_len: usize = 1000;
 
    let rent = Rent::get()?;
    let rent_lamports = rent.minimum_balance(account_len);
 
    invoke_signed(
        &system_instruction::create_account(
        initializer.key,
        pda_account.key,
        rent_lamports,
        account_len.try_into().unwrap(),
        program_id,
        ),
        &[initializer.clone(), pda_account.clone(), system_program.clone()],
        &[&[initializer.key.as_ref(), title.as_bytes().as_ref(), &[bump_seed]]],
    )?;
 
    msg!("PDA created: {}", pda);
 
    msg!("unpacking state account");
    let mut account_data = try_from_slice_unchecked::<MovieAccountState>(&pda_account.data.borrow()).unwrap();
    msg!("borrowed account data");
 
    msg!("checking if movie account is already initialized");
    if account_data.is_initialized() {
        msg!("Account already initialized");
        return Err(ProgramError::AccountAlreadyInitialized);
    }
 
    account_data.title = title;
    account_data.rating = rating;
    account_data.description = description;
    account_data.is_initialized = true;
 
    msg!("serializing account");
    account_data.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;
    msg!("state account serialized");
 
    Ok(())
}

4. Support Movie Review Updates in MovieInstruction #

Next, we'll modify instruction.rs to add support for updating movie reviews. We'll introduce a new UpdateMovieReview() variant in MovieInstruction:

instruction.rs
pub enum MovieInstruction {
    AddMovieReview {
        title: String,
        rating: u8,
        description: String
    },
    UpdateMovieReview {
        title: String,
        rating: u8,
        description: String
    }
}

The payload struct can stay the same since aside from the variant type, the instruction data is the same as what we used for AddMovieReview().

We'll also update the unpack() function to handle UpdateMovieReview().

instruction.rs
// Inside instruction.rs
impl MovieInstruction {
    pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
        let (&variant, rest) = input.split_first().ok_or(ProgramError::InvalidInstructionData)?;
        let payload = MovieReviewPayload::try_from_slice(rest).unwrap();
        Ok(match variant {
            0 => Self::AddMovieReview {
                title: payload.title,
                rating: payload.rating,
                description: payload.description },
            1 => Self::UpdateMovieReview {
                title: payload.title,
                rating: payload.rating,
                description: payload.description },
            _ => return Err(ProgramError::InvalidInstructionData)
        })
    }
}

5. Define update_movie_review Function #

Now that we can unpack our instruction_data and determine which instruction of the program to run, we can add UpdateMovieReview() to the match statement in the process_instruction() function in the processor.rs file.

processor.rs
// Inside processor.rs
pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8]
) -> ProgramResult {
    // Unpack instruction data
    let instruction = MovieInstruction::unpack(instruction_data)?;
    match instruction {
        MovieInstruction::AddMovieReview { title, rating, description } => {
            add_movie_review(program_id, accounts, title, rating, description)
        },
        // Add UpdateMovieReview to match against our new data structure
        MovieInstruction::UpdateMovieReview { title, rating, description } => {
            // Make call to update function that we'll define next
            update_movie_review(program_id, accounts, title, rating, description)
        }
    }
}

Next, we can define the new update_movie_review() function. The definition should have the same parameters as the definition of add_movie_review.

processor.rs
pub fn update_movie_review(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    title: String,
    rating: u8,
    description: String
) -> ProgramResult {
 
}

6. Implement update_movie_review Function #

All that's left now is to fill in the logic for updating a movie review. Only let's make it secure from the start.

Just like the add_movie_review() function, let's start by iterating through the accounts. The only accounts we'll need are the first two: initializer and pda_account.

processor.rs
pub fn update_movie_review(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    title: String,
    rating: u8,
    description: String
) -> ProgramResult {
    msg!("Updating movie review...");
 
    // Get Account iterator
    let account_info_iter = &mut accounts.iter();
 
    // Get accounts
    let initializer = next_account_info(account_info_iter)?;
    let pda_account = next_account_info(account_info_iter)?;
 
}

Ownership Check #

Before we continue, let's implement some basic security checks. We'll start with an ownership check on for pda_account to verify that it is owned by our program. If it isn't, we'll return an InvalidOwner error.

processor.rs
if pda_account.owner != program_id {
    return Err(ProgramError::InvalidOwner)
}

Signer Check #

Next, let's perform a signer check to verify that the initializer of the update instruction has also signed the transaction. Since we are updating the data for a movie review, we want to ensure that the original initializer of the review has approved the changes by signing the transaction. If the initializer did not sign the transaction, we'll return an error.

processor.rs
if !initializer.is_signer {
    msg!("Missing required signature");
    return Err(ProgramError::MissingRequiredSignature)
}

Account Validation #

Next, let's check that the pda_account passed in by the user is the PDA we expect by deriving the PDA using initializer and title as seeds. If the addresses do not match, we'll return our custom InvalidPDA error. We'll implement this the same way we did in the add_movie_review() function.

processor.rs
// Derive PDA and check that it matches client
let (pda, _bump_seed) = Pubkey::find_program_address(&[initializer.key.as_ref(), account_data.title.as_bytes().as_ref(),], program_id);
 
if pda != *pda_account.key {
    msg!("Invalid seeds for PDA");
    return Err(ReviewError::InvalidPDA.into())
}

Unpack pda_account and Perform Data Validation #

Now that our code ensures we can trust the passed in accounts, let's unpack the pda_account and perform some data validation. We'll start by unpacking pda_account and assigning it to a mutable variable account_data.

processor.rs
msg!("unpacking state account");
let mut account_data = try_from_slice_unchecked::<MovieAccountState>(&pda_account.data.borrow()).unwrap();
msg!("borrowed account data");

Now that we have access to the account and its fields, the first thing we need to do is verify that the account has already been initialized. An uninitialized account can't be updated so the program should return our custom UninitializedAccount error.

if !account_data.is_initialized() {
    msg!("Account is not initialized");
    return Err(ReviewError::UninitializedAccount.into());
}

Next, we need to validate the rating, title, and description data just like in the add_movie_review() function. We want to limit the rating to a scale of 1 to 5 and limit the overall size of the review to be fewer than 1000 bytes. If the rating provided by the user is outside of this range, then we'll return our custom InvalidRating error. If the review is too long, then we'll return our custom InvalidDataLength error.

processor.rs
if rating > 5 || rating < 1 {
    msg!("Rating cannot be higher than 5");
    return Err(ReviewError::InvalidRating.into())
}
 
let total_len: usize = 1 + 1 + (4 + account_data.title.len()) + (4 + description.len());
if total_len > 1000 {
    msg!("Data length is larger than 1000 bytes");
    return Err(ReviewError::InvalidDataLength.into())
}

Update the movie review account #

Now that we've implemented all of the security checks, we can finally update the movie review account by updating account_data and re-serializing it. At that point, we can return Ok from our program.

processor.rs
account_data.rating = rating;
account_data.description = description;
 
account_data.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;
 
Ok(())

All together, the update_movie_review() function should look something like the code snippet below. We've included some additional logging for clarity in debugging.

processor.rs
pub fn update_movie_review(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    title: String,
    rating: u8,
    description: String
) -> ProgramResult {
    msg!("Updating movie review...");
 
    let account_info_iter = &mut accounts.iter();
 
    let initializer = next_account_info(account_info_iter)?;
    let pda_account = next_account_info(account_info_iter)?;
 
    if pda_account.owner != program_id {
      return Err(ProgramError::IllegalOwner)
    }
 
    if !initializer.is_signer {
        msg!("Missing required signature");
        return Err(ProgramError::MissingRequiredSignature)
    }
 
    msg!("unpacking state account");
    let mut account_data = try_from_slice_unchecked::<MovieAccountState>(&pda_account.data.borrow()).unwrap();
    msg!("review title: {}", account_data.title);
 
    let (pda, _bump_seed) = Pubkey::find_program_address(&[initializer.key.as_ref(), account_data.title.as_bytes().as_ref(),], program_id);
    if pda != *pda_account.key {
        msg!("Invalid seeds for PDA");
        return Err(ReviewError::InvalidPDA.into())
    }
 
    msg!("checking if movie account is initialized");
    if !account_data.is_initialized() {
        msg!("Account is not initialized");
        return Err(ReviewError::UninitializedAccount.into());
    }
 
    if rating > 5 || rating < 1 {
        msg!("Invalid Rating");
        return Err(ReviewError::InvalidRating.into())
    }
 
    let update_len: usize = 1 + 1 + (4 + description.len()) + account_data.title.len();
    if update_len > 1000 {
        msg!("Data length is larger than 1000 bytes");
        return Err(ReviewError::InvalidDataLength.into())
    }
 
    msg!("Review before update:");
    msg!("Title: {}", account_data.title);
    msg!("Rating: {}", account_data.rating);
    msg!("Description: {}", account_data.description);
 
    account_data.rating = rating;
    account_data.description = description;
 
    msg!("Review after update:");
    msg!("Title: {}", account_data.title);
    msg!("Rating: {}", account_data.rating);
    msg!("Description: {}", account_data.description);
 
    msg!("serializing account");
    account_data.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;
    msg!("state account serialized");
 
    Ok(())
}

7. Build and upgrade #

We're ready to build and upgrade our program! You can test your program by submitting a transaction with the right instruction data. For that, feel free to use this frontend. Remember, to make sure you're testing the right program you'll need to replace MOVIE_REVIEW_PROGRAM_ID with your program ID in Form.tsx and MovieCoordinator.ts.

If you need more time with this project to feel comfortable with these concepts, have a look at the solution code before continuing.

Challenge #

Now it's your turn to build something independently by building on top of the Student Intro program that you've used in previous lessons. If you haven't been following along or haven't saved your code before, feel free to use this starter code.

The Student Intro program is a Solana Program that lets students introduce themselves. The program takes a user's name and a short message as the instruction_data and creates an account to store the data onchain.

Using what you've learned in this lesson, try applying what you've learned to the Student Intro Program. The program should:

  1. Add an instruction allowing students to update their message
  2. Implement the basic security checks we've learned in this lesson

Try to do this independently if you can! But if you get stuck, feel free to reference the solution code. Note that your code may look slightly different than the solution code depending on the checks you implement and the errors you write.

Completed the lab?

Push your code to GitHub and tell us what you thought of this lesson!