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.
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:
-
Have logic in place that ensures overflow or underflow cannot happen or
-
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.
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.
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.
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.
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.
// 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.
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.
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.
if account_data.is_initialized() {
msg!("Account already initialized");
return Err(ProgramError::AccountAlreadyInitialized);
}
Altogether, the add_movie_review()
function should look something like this:
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
:
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()
.
// 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.
// 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
.
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
.
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.
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.
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.
// 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
.
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.
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.
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.
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:
- Add an instruction allowing students to update their message
- 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.
Push your code to GitHub and tell us what you thought of this lesson!