Implementing checks on third-party state

In the above discussion, we have determined that our vault should be owned by an account our program controls and can sign for. This is where program-derived addresses (PDAs) come into play.

Aside: what are Program-Derived Addresses (PDAs)?

PDAs are Solana's solution for a recurring generic problem in program design : how can our program own anything of value? Conventionally, owning an asset on a blockchain means controlling a private key the public key of which is set as its owner. Then, users can sign transactions to authorize operations on assets they own. The key to all of this is that the private key is only known to its owner.

Sometimes, we need a program to own something in the same way that a user does. However, any information a program has access to is available to anyone, so we can't really give a private key to our program. What we need is a public key which isn't tied to a private key, but instead to a particular program. This is precisely what PDAs are.

A PDA is uniquely derived from an array of seeds. To generate a PDA, we can use one of two methods Pubkey::find_program_address, or Pubkey::create_program_address. The difference between the two is that create_program_address needs valid seeds which generate a valid PDA. A valid PDA is a Pubkey which we're sure doesn't have a private key. It lies outside the ed25519 curve used by Solana. find_program_address will take a seed array and find a u8 to add to the seed array such that the resulting array is a valid PDA seed. We call this u8 the signer_nonce.

We can relate the two methods:

let seeds: &[u8] = &[...];
let (k, nonce) = Pubkey::find_program_address(&[seeds], program_id).unwrap();
let k2 = Pubkey::create_program_address(&[seeds, &[signer_nonce]]).unwrap();
// k and k2 will be the same
assert_eq!(k, k2);

In general, find_program_address can be relatively inefficient: it loops over potential nonces until it finds a valid one. The real issue is that the length of this loop is quite unpredictable. In most cases, it will be very short. This means that the entirety of your testing scenarios can only test for short loops, and edge cases can show up in production later down the line. Therefore, as a general rule of thumb, find_program_address should be avoided on-chain because it consumes an unreliable amount of compute budget. There are exceptions to this rule, especially when PDAs are used as a mapping function. This is why we ask the user to provide a signer_nonce: find_program_address is executed off-chain.

A PDA's seeds serve a similar purpose to a private key. When a program needs to sign for a PDA, it uses the PDA's seeds in a call to invoke_signed. The runtime can then make sure that the PDA to sign for corresponds to those seeds and the calling program.

To add a layer of security, we want our vesting contract instances to be as compartmentalized as possible. Therefore, we will use the VestingContract key as seeds. Each instance already has its own vault, and this allows each contract to have its own separate signing authority. The alternative to this is related to the idea of central state, which can be required by some use cases and is supported by bonfida-utils.

Writing the check_vault_account method

Using the constraints we came up with by analyzing the spl_token::Account struct, we can add to create.rs:

fn check_vault_account(
    vault: &AccountInfo,
    program_id: &Pubkey,
    contract_key: Pubkey,
    signer_nonce: u8,
) -> Result<(), ProgramError> {
    // We parse the Account struct using the Solana-provided Pack trait
    let vault_account = spl_token::state::Account::unpack(&vault.data.borrow())?;

    let vault_signer =
        Pubkey::create_program_address(&[&contract_key.to_bytes(), &[signer_nonce]], program_id)?;
    let is_valid = vault_account.owner == vault_signer
        && vault_account.amount == 0
        && vault_account.delegate.is_none()
        && vault_account.state == AccountState::Initialized
        && vault_account.close_authority.is_none();
    if !is_valid {
        return Err(TokenVestingError::InvalidVaultAccount.into());
    }
    Ok(())
}

If the provided vault account is invalid, we return the custom InvalidVaultAccount error. Let's define it in error.rs by modifying the TokenVestingError struct:

#[derive(Clone, Debug, Error, FromPrimitive)]
pub enum TokenVestingError {
    #[error("This account is already initialized")]
    AlreadyInitialized,
    #[error("Data type mismatch")]
    DataTypeMismatch,
    #[error("Wrong account owner")]
    WrongOwner,
    #[error("Account is uninitialized")]
    Uninitialized,
    #[error("The provided vault account is invalid")]
    InvalidVaultAccount,
}

Our project does not compile at this point, and we need to edit TokenVestingError's impl of the PrintProgramError trait in entrypoint.rs:

impl PrintProgramError for TokenVestingError {
    fn print<E>(&self)
    where
        E: 'static + std::error::Error + DecodeError<E> + PrintProgramError + FromPrimitive,
    {
        match self {
            TokenVestingError::AlreadyInitialized => {
                msg!("Error: This account is already initialized")
            }
            TokenVestingError::DataTypeMismatch => msg!("Error: Data type mismatch"),
            TokenVestingError::WrongOwner => msg!("Error: Wrong account owner"),
            TokenVestingError::Uninitialized => msg!("Error: Account is uninitialized"),
            TokenVestingError::InvalidVaultAccount => {
                msg!("Error: The provided vault account is invalid")
            }
        }
    }
}

It might seem a bit redundant to have the same error message appear twice in our project. However, doing it this way prevents our PrintProgramError implementation from having to use string formatting which is inefficient on-chain. The last thing we want is our error messages to be buried under a ComputationalBudgetExceeded error.

Finally we just have to insert a call to check_vault_account in our create instruction's logic:

check_vault_account(
    accounts.vault,
    program_id,
    *accounts.vesting_contract.key,
    signer_nonce,
)?;