CPI Guard

Summary #

  • CPI Guard is a token account extension from the Token Extensions Program
  • The CPI Guard extension prohibits certain actions inside cross-program invocations. When enabled, the guard provides protections against various potentially malicious actions on a token account
  • CPI Guard can be enabled or disabled at will
  • These protections are enforced within the Token Extensions Program itself

Overview #

CPI Guard is an extension that prohibits certain actions inside cross-program invocations, protecting users from implicitly signing for actions they can't see, such as those hidden in programs that aren't the System or Token programs.

A specific example of this is when the CPI gaurd is enabled, no CPI can approve a delegate over a token account. This is handy, because if a malicious CPI calls set_delegate no immediate balance change will be apparent, however the attacker now has transfer and burn authority over the token account. CPI gaurd makes this impossible.

Users may choose to enable or disable the CPI Guard extension on their token account at will. When enabled, it has the following effects during a CPI:

  • Transfer: the signing authority must be the owner or previously established account delegate
  • Burn: the signing authority must be the owner or previously established account delegate
  • Approve: prohibited - no delegates can be approved within the CPI
  • Close Account: the lamport destination must be the account owner
  • Set Close Authority: prohibited unless unsetting
  • Set Owner: always prohibited, including outside CPI

The CPI Guard is a token account extension, meaning each individual Token Extensions Program token account has to enable it.

How the CPI Guard Works #

The CPI Guard can be enabled and disabled on a token account that was created with enough space for the extension. The Token Extensions Program runs a few checks in the logic related to the above actions to determine if it should allow an instruction to continue or not related to CPI Guards. Generally, what it does is the following:

  • Check if the account has the CPI Guard extension
  • Check if CPI Guard is enabled on the token account
  • Check if the function is being executed within a CPI

A good way to think about the CPI Guard token extension is simply as a lock that is either enabled or disabled. The guard uses a data struct called CpiGuard that stores a boolean value. That value indicates whether the guard is enabled or disabled. The CPI Guard extension only has two instructions, Enable and Disable. They each toggle this boolean value.

pub struct CpiGuard {
    /// Lock privileged token operations from happening via CPI
    pub lock_cpi: PodBool,
}

The CPI Guard has two additional helper functions that the Token Extensions Program is able to use to help determine when the CPI Guard is enabled and when the instruction is being executed as part of a CPI. The first, cpi_guard_enabled(), simply returns the current value of the CpiGuard.lock_cpi field if the extension exists on the account, otherwise, it returns false. The rest of the program can use this function to determine if the guard is enabled or not.

/// Determine if CPI Guard is enabled for this account
pub fn cpi_guard_enabled(account_state: &StateWithExtensionsMut<Account>) -> bool {
    if let Ok(extension) = account_state.get_extension::<CpiGuard>() {
        return extension.lock_cpi.into();
    }
    false
}

The second helper function is called in_cpi() and determines whether or not the current instruction is within a CPI. The function is able to determine if it's currently in a CPI by calling get_stack_height() from the solana_program rust crate. This function returns the current stack height of instructions. Instructions created at the initial transaction level will have a height of TRANSACTION_LEVEL_STACK_HEIGHT or 1. The first inner invoked transaction, or CPI, will have a height of TRANSACTION_LEVEL_STACK_HEIGHT + 1 and so on. With this information, we know that if get_stack_height() returns a value greater than TRANSACTION_LEVEL_STACK_HEIGHT, we're currently in a CPI! This is exactly what the in_cpi() function checks. If get_stack_height() {'>'} TRANSACTION_LEVEL_STACK_HEIGHT, it returns True. Otherwise, it returns False.

/// Determine if we are in CPI
pub fn in_cpi() -> bool {
    get_stack_height() > TRANSACTION_LEVEL_STACK_HEIGHT
}

Using these two helper functions, the Token Extensions Program can easily determine if it should reject an instruction or not.

Toggle CPI Guard #

To toggle the CPI Guard on/off, a Token Account must have been initialized for this specific extension. Then, an instruction can be sent to enable the CPI Guard. This can only be done from a client. You cannot toggle the CPI Guard via CPI. The Enable instruction checks if it was invoked via CPI and will return an error if so. This means only the end user can toggle the CPI Guard.

// inside process_toggle_cpi_guard()
if in_cpi() {
    return Err(TokenError::CpiGuardSettingsLocked.into());
}

You can enable the CPI using the @solana/spl-token Typescript package. Here is an example.

// create token account with the CPI Guard extension
const tokenAccount = tokenAccountKeypair.publicKey;
const extensions = [ExtensionType.CpiGuard];
const tokenAccountLen = getAccountLen(extensions);
const lamports =
  await connection.getMinimumBalanceForRentExemption(tokenAccountLen);
 
const createTokenAccountInstruction = SystemProgram.createAccount({
  fromPubkey: payer.publicKey,
  newAccountPubkey: tokenAccount,
  space: tokenAccountLen,
  lamports,
  programId: TOKEN_2022_PROGRAM_ID,
});
 
// create 'enable CPI Guard' instruction
const enableCpiGuardInstruction = createEnableCpiGuardInstruction(
  tokenAccount,
  owner.publicKey,
  [],
  TOKEN_2022_PROGRAM_ID,
);
 
const initializeAccountInstruction = createInitializeAccountInstruction(
  tokenAccount,
  mint,
  owner.publicKey,
  TOKEN_2022_PROGRAM_ID,
);
 
// construct transaction with these instructions
const transaction = new Transaction().add(
  createTokenAccountInstruction,
  initializeAccountInstruction,
  enableCpiGuardInstruction,
);
 
transaction.feePayer = payer.publicKey;
// Send transaction
await sendAndConfirmTransaction(connection, transaction, [
  payer,
  owner,
  tokenAccountKeypair,
]);

You can also use the enableCpiGuard and disableCpiGuard helper functions from the @solana/spl-token API after the account as been initialized.

// enable CPI Guard
await enableCpiGuard(
  connection, // connection
  payer, // payer
  userTokenAccount.publicKey, // account
  payer, // owner
  [], // multiSigners
);
 
// disable CPI Guard
await disableCpiGuard(
  connection, // connection
  payer, // payer
  userTokenAccount.publicKey, // account
  payer, // owner
  [], // multiSigners
);

CPI Guard Protections #

Transfer #

The transfer feature of the CPI Guard prevents anyone but the account delegate from authorizing a transfer instruction. This is enforced in the various transfer functions in the Token Extensions Program. For example, looking at the transfer instruction we can see a check that will return an error if the required circumstances are met.

Using the helper functions we discussed above, the program is able to determine if it should throw an error or not.

// inside process_transfer in the token extensions program
if let Ok(cpi_guard) = source_account.get_extension::<CpiGuard>() {
    if cpi_guard.lock_cpi.into() && in_cpi() {
        return Err(TokenError::CpiGuardTransferBlocked.into());
    }
}

This guard means that not even the owner of a token account can transfer tokens out of the account while another account is an authorized delegate.

Burn #

This CPI Guard also ensures only the account delegate can burn tokens from a token account, just like the transfer protection.

The process_burn function in the Token Extension Program functions in the same way as the transfer instructions. It will return an error under the same circumstances.

// inside process_burn in the token extensions program
if let Ok(cpi_guard) = source_account.get_extension::<CpiGuard>() {
    if cpi_guard.lock_cpi.into() && in_cpi() {
        return Err(TokenError::CpiGuardBurnBlocked.into());
    }
}

This guard means that not even the owner of a token account can burn tokens out of the account while another account is an authorized delegate.

Approve #

The CPI Guard prevents from approving a delegate of a token account via CPI. You can approve a delegate via a client instruction, but not CPI. The process_approve function of the Token Extension Program runs the same checks to determine if the guard is enabled and its currently in a CPI.

This means an end user is not at risk of signing a transaction that indirectly approves a delegate over their token account without the knowledge of the user. Before, the user was at the mercy of their wallet to notify them of transactions like this ahead of time.

Close #

To close a token account via CPI, having the guard enabled means that the Token Extensions Program will check that the destination account receiving the token account's lamports is the account owner.

Here is the exact code block from the process_close_account function.

if !source_account
    .base
    .is_owned_by_system_program_or_incinerator()
{
    if let Ok(cpi_guard) = source_account.get_extension::<CpiGuard>() {
        if cpi_guard.lock_cpi.into()
            && in_cpi()
            && !cmp_pubkeys(destination_account_info.key, &source_account.base.owner)
        {
            return Err(TokenError::CpiGuardCloseAccountBlocked.into());
        }
    }
...
}

This guard protects the user from signing a transaction that closes a token account they own and transferring that account's lamports to another account via CPI. This would be hard to detect from an end user's perspective without inspecting the instructions themselves. This guard ensures those lamports are transferred only to their owner when closing a token account via CPI.

Set Close Authority #

The CPI Guard prevents from setting the CloseAccount authority via CPI, you can unset a previously set CloseAccount authority however. The Token Extension Program enforces this by checking if a value has been passed in the new_authority parameter to the process_set_authority function.

AuthorityType::CloseAccount => {
    let authority = account.base.close_authority.unwrap_or(account.base.owner);
    Self::validate_owner(
        program_id,
        &authority,
        authority_info,
        authority_info_data_len,
        account_info_iter.as_slice(),
    )?;
 
    if let Ok(cpi_guard) = account.get_extension::<CpiGuard>() {
        if cpi_guard.lock_cpi.into() && in_cpi() && new_authority.is_some() {
            return Err(TokenError::CpiGuardSetAuthorityBlocked.into());
        }
    }
 
    account.base.close_authority = new_authority;
}

This guard prevents the user from signing a transaction that gives another account the ability to close their Token account behind the scenes.

Set Owner #

The CPI Guard prevents from changing the account owner in all circumstances, whether via CPI or not. The account authority is updated in the same process_set_authority function as the CloseAccount authority in the previous section. If the instruction is attempting to update the authority of an account with the CPI Guard enabled, the function will return one of two possible errors.

If the instruction is being executed in a CPI, the function will return a CpiGuardSetAuthorityBlocked error. Otherwise it will return a CpiGuardOwnerChangeBlocked error.

if let Ok(cpi_guard) = account.get_extension::<CpiGuard>() {
    if cpi_guard.lock_cpi.into() && in_cpi() {
        return Err(TokenError::CpiGuardSetAuthorityBlocked.into());
    } else if cpi_guard.lock_cpi.into() {
        return Err(TokenError::CpiGuardOwnerChangeBlocked.into());
    }
}

This guard prevents from changing the ownership of a Token account at all times when enabled.

Lab #

This lab will primarily focus on writing tests in TypeScript, but we'll need to run a program locally against these tests. For this reason, we'll need to go through a few steps to ensure a proper environment on your machine for the program to run. The onchain program has already been written for you and is included in the lab starter code.

The onchain program contains a few instructions that showcase what the CPI Guard can protect against. We'll write tests invoking these instructions both with a CPI Guard enabled and disabled.

The tests have been broken up into individual files in the /tests directory. Each file serves as its own unit test that will invoke a specific instruction on our program and illustrate a specific CPI Guard.

The program has five instructions: malicious_close_account, prohibited_approve_account, prohibited_set_authority, unauthorized_burn, set_owner.

Each of these instructions makes a CPI to the Token Extensions Program and attempts to take an action on the given token account that is potentially malicious unknowingly to the signer of the original transaction. We won't test the Transfer guard as it is same as the Burn guard.

1. Verify Solana/Anchor/Rust Versions #

We'll be interacting with the Token Extensions Program in this lab and that requires you to have Solana CLI version ≥ 1.18.0.

To check your version run:

solana --version

If the version printed out after running solana --version is less than 1.18.0 then you can update the CLI version manually. Note, at the time of writing this, you cannot simply run the solana-install update command. This command will not update the CLI to the correct version for us, so we have to explicitly download version 1.18.0. You can do so with the following command:

solana-install init 1.18.0

If you run into this error at any point attempting to build the program, that likely means you do not have the correct version of the Solana CLI installed.

anchor build
error: package `solana-program v1.18.0` cannot be built because it requires rustc 1.72.0 or newer, while the currently active rustc version is 1.68.0-dev
Either upgrade to rustc 1.72.0 or newer, or use
cargo update -p solana-program@1.18.0 --precise ver
where `ver` is the latest version of `solana-program` supporting rustc 1.68.0-dev

You will also want the 0.29.0 version of the Anchor CLI installed. You can follow the steps listed here to update via avm https://www.anchor-lang.com/docs/avm

or simply run

avm install 0.29.0
avm use 0.29.0

At the time of writing, the latest version of the Anchor CLI is 0.29.0

Now, we can check our rust version.

rustc --version

At the time of writing, version 1.26.0 was used for the Rust compiler. If you would like to update, you can do so via rustup https://doc.rust-lang.org/book/ch01-01-installation.html

rustup update

Now, we should have all the correct versions installed.

2. Get starter code and add dependencies #

Let's grab the starter branch.

git clone https://github.com/Unboxed-Software/solana-lab-cpi-guard
cd solana-lab-cpi-guard
git checkout starter

3. Update Program ID and Anchor Keypair #

Once in the starter branch, run

anchor keys sync

This will replace the program ID in various locations with your new program keypair.

Then set your developer keypair path in Anchor.toml.

[provider]
cluster = "Localnet"
wallet = "~/.config/solana/id.json"

"~/.config/solana/id.json" is the most common keypair path, but if you're unsure, just run:

solana config get

4. Confirm the program builds #

Let's build the starter code to confirm we have everything configured correctly. If it does not build, please revisit the steps above.

anchor build

You can safely ignore the warnings of the build script, these will go away as we add in the necessary code.

Feel free to run the provided tests to make sure the rest of the dev environment is setup correctly. You'll have to install the node dependencies using npm or yarn. The tests should run, but they do not do anything currently.

yarn install
anchor test

5. Create token with CPI Guard #

Before we write any tests, let's create a helper function that will create a Token account with the CPI Guard extension. Let's do this in a new file tests/token-helper.ts and a new function called createTokenAccountWithCPIGuard.

Internally, this function will call:

  • SystemProgram.createAccount: Allocates space for the token account
  • createInitializeAccountInstruction: Initializes the token account
  • createEnableCpiGuardInstruction: Enables the CPI Guard
import {
  ExtensionType,
  TOKEN_2022_PROGRAM_ID,
  createEnableCpiGuardInstruction,
  createInitializeAccountInstruction,
  getAccountLen,
} from "@solana/spl-token";
import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
} from "@solana/web3.js";
 
export async function createTokenAccountWithCPIGuard(
  connection: Connection,
  payer: Keypair,
  owner: Keypair,
  tokenAccountKeypair: Keypair,
  mint: PublicKey,
): Promise<string> {
  const tokenAccount = tokenAccountKeypair.publicKey;
 
  const extensions = [ExtensionType.CpiGuard];
 
  const tokenAccountLen = getAccountLen(extensions);
  const lamports =
    await connection.getMinimumBalanceForRentExemption(tokenAccountLen);
 
  const createTokenAccountInstruction = SystemProgram.createAccount({
    fromPubkey: payer.publicKey,
    newAccountPubkey: tokenAccount,
    space: tokenAccountLen,
    lamports,
    programId: TOKEN_2022_PROGRAM_ID,
  });
 
  const initializeAccountInstruction = createInitializeAccountInstruction(
    tokenAccount,
    mint,
    owner.publicKey,
    TOKEN_2022_PROGRAM_ID,
  );
 
  const enableCpiGuardInstruction = createEnableCpiGuardInstruction(
    tokenAccount,
    owner.publicKey,
    [],
    TOKEN_2022_PROGRAM_ID,
  );
 
  const transaction = new Transaction().add(
    createTokenAccountInstruction,
    initializeAccountInstruction,
    enableCpiGuardInstruction,
  );
 
  transaction.feePayer = payer.publicKey;
 
  // Send transaction
  return await sendAndConfirmTransaction(connection, transaction, [
    payer,
    owner,
    tokenAccountKeypair,
  ]);
}

5. Approve delegate #

The first CPI Guard we'll test is the approve delegate functionality. The CPI Guard prevents approving a delegate of a token account with the CPI Guard enabled via CPI completely. It's important to note that you can approve a delegate on a CPI Guarded account, just not with a CPI. To do so, you must send an instruction directly to the Token Extensions Program from a client rather than via another program.

Before we write our test, we need to take a look at the program code we are testing. The prohibited_approve_account instruction is what we'll be targeting here.

// inside src/lib.rs
pub fn prohibited_approve_account(ctx: Context<ApproveAccount>, amount: u64) -> Result<()> {
    msg!("Invoked ProhibitedApproveAccount");
 
    msg!(
        "Approving delegate: {} to transfer up to {} tokens.",
        ctx.accounts.delegate.key(),
        amount
    );
 
    approve(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Approve {
                to: ctx.accounts.token_account.to_account_info(),
                delegate: ctx.accounts.delegate.to_account_info(),
                authority: ctx.accounts.authority.to_account_info(),
            },
        ),
        amount,
    )
}
...
 
#[derive(Accounts)]
pub struct ApproveAccount<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        mut,
        token::token_program = token_program,
        token::authority = authority
    )]
    pub token_account: InterfaceAccount<'info, token_interface::TokenAccount>,
    /// CHECK: delegat to approve
    #[account(mut)]
    pub delegate: AccountInfo<'info>,
    pub token_program: Interface<'info, token_interface::TokenInterface>,
}

If you are familiar with Solana programs, then this should look like a pretty simple instruction. The instruction expects an authority account as a Signer and a token_account that authority is the authority of.

The instruction then invokes the Approve instruction on the Token Extensions Program and attempts to assign delegate as the delegate over the given token_account.

Let's open the /tests/approve-delegate-example.ts file to begin testing this instruction. Take a look at the starting code. We have a payer, some test keypairs and an airdropIfRequired function that will run before the tests.

Once you feel comfortable with the starting code, we can move on to the 'Approve Delegate' tests. We will make tests that invoke the same exact instruction in our target program, with and without CPI guard.

To test our instruction, we first need to create our token mint and a token account with extensions.

it("stops 'Approve Delegate' when CPI guard is enabled", async () => {
  await createMint(
    provider.connection,
    payer,
    provider.wallet.publicKey,
    undefined,
    6,
    testTokenMint,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
  await createTokenAccountWithCPIGuard(
    provider.connection,
    payer,
    payer,
    userTokenAccount,
    testTokenMint.publicKey,
  );
});

Now let's send a transaction to our program that will attempt to invoke the 'Approve delegate' instruction on the Token Extensions Program.

// inside "allows 'Approve Delegate' when CPI guard is disabled" test block
try {
  const tx = await program.methods
    .prohibitedApproveAccount(new anchor.BN(1000))
    .accounts({
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      delegate: maliciousAccount.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
 
  console.log("Your transaction signature", tx);
} catch (e) {
  assert(
    e.message ==
      "failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x2d",
  );
  console.log(
    "CPI Guard is enabled, and a program attempted to approve a delegate",
  );
}

Notice we wrap this in a try/catch block. This is because this instruction should fail if the CPI Guard works correctly. We catch the error and assert that the error message is what we expect.

Now, we essentially do the same thing for the "allows 'Approve Delegate' when CPI guard is disabled" test, except we want to pass in a token account without a CPI Guard. To do this, we can simply disable the CPI Guard on the userTokenAccount and resend the transaction.

it("allows 'Approve Delegate' when CPI guard is disabled", async () => {
  await disableCpiGuard(
    provider.connection,
    payer,
    userTokenAccount.publicKey,
    payer,
    [],
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await program.methods
    .prohibitedApproveAccount(new anchor.BN(1000))
    .accounts({
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      delegate: maliciousAccount.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
});

This transaction will succeed and the delegate account will now have the authority to transfer the given amount of tokens from the userTokenAccount.

Feel free to save your work and run anchor test. All of the tests will run, but these two are the only ones that are doing anything yet. They should both pass at this point.

6. Close Account #

The close account instruction invokes the close_account instruction on the Token Extensions Program. This closes the given token account. However, you have the ability to define which account the returned rent lamports should be transferred to. The CPI Guard ensures that this account is always the account owner.

pub fn malicious_close_account(ctx: Context<MaliciousCloseAccount>) -> Result<()> {
    msg!("Invoked MaliciousCloseAccount");
 
    msg!(
        "Token account to close : {}",
        ctx.accounts.token_account.key()
    );
 
    close_account(CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        CloseAccount {
            account: ctx.accounts.token_account.to_account_info(),
            destination: ctx.accounts.destination.to_account_info(),
            authority: ctx.accounts.authority.to_account_info(),
        },
    ))
}
 
...
 
#[derive(Accounts)]
pub struct MaliciousCloseAccount<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        mut,
        token::token_program = token_program,
        token::authority = authority
    )]
    pub token_account: InterfaceAccount<'info, token_interface::TokenAccount>,
    /// CHECK: malicious account
    #[account(mut)]
    pub destination: AccountInfo<'info>,
    pub token_program: Interface<'info, token_interface::TokenInterface>,
    pub system_program: Program<'info, System>,
}

Our program just invokes the close_account instruction, but a potentially malicious client could pass in a different account than the token account owner as the destination account. This would be hard to see from a user's perspective unless the wallet notified them. With CPI Guards enabled, the Token Extension Program will simply reject the instruction if that is the case.

To test this, we'll open up the /tests/close-account-example.ts file. The starting code here is the same as our previous test.

First, let's create our mint and CPI guarded token account:

it("stops 'Close Account' when CPI guard in enabled", async () => {
  await createMint(
    provider.connection,
    payer,
    provider.wallet.publicKey,
    undefined,
    6,
    testTokenMint,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
  await createTokenAccountWithCPIGuard(
    provider.connection,
    payer,
    payer,
    userTokenAccount,
    testTokenMint.publicKey,
  );
});

Now let's send a transaction to our malicious_close_account instruction. Since we have the CPI Guard enabled on this token account, the transaction should fail. Our test verifies it fails for the expected reason.

// inside "stops 'Close Account' when CPI guard in enabled" test block
try {
  const tx = await program.methods
    .maliciousCloseAccount()
    .accounts({
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      destination: maliciousAccount.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
 
  console.log("Your transaction signature", tx);
} catch (e) {
  assert(
    e.message ==
      "failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x2c",
  );
  console.log(
    "CPI Guard is enabled, and a program attempted to close an account without returning lamports to owner",
  );
}

Now, we can disable the CPI Guard and send the same exact transaction in the "Close Account without CPI Guard" test. This transaction should succeed this time.

it("Close Account without CPI Guard", async () => {
  await disableCpiGuard(
    provider.connection,
    payer,
    userTokenAccount.publicKey,
    payer,
    [],
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await program.methods
    .maliciousCloseAccount()
    .accounts({
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      destination: maliciousAccount.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
});

7. Set Authority #

Moving on to the prohibited_set_authority instruction, the CPI Guard protects against a CPI setting the CloseAccount authority.

pub fn prohibted_set_authority(ctx: Context<SetAuthorityAccount>) -> Result<()> {
    msg!("Invoked ProhibitedSetAuthority");
 
    msg!(
        "Setting authority of token account: {} to address: {}",
        ctx.accounts.token_account.key(),
        ctx.accounts.new_authority.key()
    );
 
    set_authority(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            SetAuthority {
                current_authority: ctx.accounts.authority.to_account_info(),
                account_or_mint: ctx.accounts.token_account.to_account_info(),
            },
        ),
        spl_token_2022::instruction::AuthorityType::CloseAccount,
        Some(ctx.accounts.new_authority.key()),
    )
}
 
#[derive(Accounts)]
pub struct SetAuthorityAccount<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        mut,
        token::token_program = token_program,
        token::authority = authority
    )]
    pub token_account: InterfaceAccount<'info, token_interface::TokenAccount>,
    /// CHECK: delegat to approve
    #[account(mut)]
    pub new_authority: AccountInfo<'info>,
    pub token_program: Interface<'info, token_interface::TokenInterface>,
}

Our program instruction simply invokes the SetAuthority instruction and indicates we want to set the spl_token_2022::instruction::AuthorityType::CloseAccount authority of the given token account.

Open the /tests/set-authority-example.ts file. The starter code is the same as the previous tests.

Let's create our mint and CPI-guarded token account. Then, we can send a transaction to our prohibited_set_authority instruction.

it("sets authority when CPI guard in enabled", async () => {
  await createMint(
    provider.connection,
    payer,
    provider.wallet.publicKey,
    undefined,
    6,
    testTokenMint,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
  await createTokenAccountWithCPIGuard(
    provider.connection,
    payer,
    payer,
    userTokenAccount,
    testTokenMint.publicKey,
  );
 
  try {
    const tx = await program.methods
      .prohibtedSetAuthority()
      .accounts({
        authority: payer.publicKey,
        tokenAccount: userTokenAccount.publicKey,
        newAuthority: maliciousAccount.publicKey,
        tokenProgram: TOKEN_2022_PROGRAM_ID,
      })
      .signers([payer])
      .rpc();
 
    console.log("Your transaction signature", tx);
  } catch (e) {
    assert(
      e.message ==
        "failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x2e",
    );
    console.log(
      "CPI Guard is enabled, and a program attempted to add or change an authority",
    );
  }
});

For the "Set Authority Example" test, we can disable the CPI Guard and re-send the transaction.

it("Set Authority Example", async () => {
  await disableCpiGuard(
    provider.connection,
    payer,
    userTokenAccount.publicKey,
    payer,
    [],
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await program.methods
    .prohibtedSetAuthority()
    .accounts({
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      newAuthority: maliciousAccount.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
});

8. Burn #

The next instruction we'll test is the unauthorized_burn instruction from our test program. This instruction invokes the burn instruction from the Token Extensions Program and attempts to burn a given amount of tokens from the given token account.

The CPI Guard ensures that this is only possible if the signing authority is the token account delegate.

pub fn unauthorized_burn(ctx: Context<BurnAccounts>, amount: u64) -> Result<()> {
    msg!("Invoked Burn");
 
    msg!(
        "Burning {} tokens from address: {}",
        amount,
        ctx.accounts.token_account.key()
    );
 
    burn(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Burn {
                mint: ctx.accounts.token_mint.to_account_info(),
                from: ctx.accounts.token_account.to_account_info(),
                authority: ctx.accounts.authority.to_account_info(),
            },
        ),
        amount,
    )
}
 
...
 
#[derive(Accounts)]
pub struct BurnAccounts<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        mut,
        token::token_program = token_program,
        token::authority = authority
    )]
    pub token_account: InterfaceAccount<'info, token_interface::TokenAccount>,
    #[account(
        mut,
        mint::token_program = token_program
    )]
    pub token_mint: InterfaceAccount<'info, token_interface::Mint>,
    pub token_program: Interface<'info, token_interface::TokenInterface>,
}

To test this, open up the tests/burn-example.ts file. The starter code is the same as the previous, except we swapped maliciousAccount to delegate.

Then, we can create our mint and CPI-guarded token account.

it("stops 'Burn' without a delegate signature", async () => {
  await createMint(
    provider.connection,
    payer,
    provider.wallet.publicKey,
    undefined,
    6,
    testTokenMint,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await createTokenAccountWithCPIGuard(
    provider.connection,
    payer,
    payer,
    userTokenAccount,
    testTokenMint.publicKey,
  );
});

Now, let's mint some tokens to our test account.

// inside "stops 'Burn' without a delegate signature" test block
const mintToTx = await mintTo(
  provider.connection,
  payer,
  testTokenMint.publicKey,
  userTokenAccount.publicKey,
  payer,
  1000,
  undefined,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);

Now let's approve a delegate over our token account. This token account has a CPI Guard enabled currently, but we are still able to approve a delegate. This is because we are doing so by invoking the Token Extensions Program directly and not via a CPI like our earlier example.

// inside "stops 'Burn' without a delegate signature" test block
const approveTx = await approve(
  provider.connection,
  payer,
  userTokenAccount.publicKey,
  delegate.publicKey,
  payer,
  500,
  undefined,
  undefined,
  TOKEN_2022_PROGRAM_ID,
);

Now that we have a delegate over our token account, we can send a transaction to our program to attempt to burn some tokens. We'll be passing in the payer account as the authority. This account is the owner over the userTokenAccount, but since we have approved the delegate account as the delegate, the CPI Guard will prevent this transaction from going through.

// inside "stops 'Burn' without a delegate signature" test block
try {
  const tx = await program.methods
    .unauthorizedBurn(new anchor.BN(500))
    .accounts({
      // payer is not the delegate
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      tokenMint: testTokenMint.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
 
  console.log("Your transaction signature", tx);
} catch (e) {
  assert(
    e.message ==
      "failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x2b",
  );
  console.log(
    "CPI Guard is enabled, and a program attempted to burn user funds without using a delegate.",
  );
}

For the "Burn without Delegate Signature Example" test, we'll simply disable the CPI Guard and re-send the transaction.

it("Burn without Delegate Signature Example", async () => {
  await disableCpiGuard(
    provider.connection,
    payer,
    userTokenAccount.publicKey,
    payer,
    [],
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  const tx = await program.methods
    .unauthorizedBurn(new anchor.BN(500))
    .accounts({
      // payer is not the delegate
      authority: payer.publicKey,
      tokenAccount: userTokenAccount.publicKey,
      tokenMint: testTokenMint.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
});

9. Set Owner #

The last CPI Guard we'll test is the SetOwner protection. With the CPI Guard enabled, this action is always prohibited even outside of a CPI. To test this, we'll attempt to set the owner of a token account from the client side, as well as CPI via our test program.

Here is the program instruction.

pub fn set_owner(ctx: Context<SetOwnerAccounts>) -> Result<()> {
    msg!("Invoked SetOwner");
 
    msg!(
        "Setting owner of token account: {} to address: {}",
        ctx.accounts.token_account.key(),
        ctx.accounts.new_owner.key()
    );
 
    set_authority(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            SetAuthority {
                current_authority: ctx.accounts.authority.to_account_info(),
                account_or_mint: ctx.accounts.token_account.to_account_info(),
            },
        ),
        spl_token_2022::instruction::AuthorityType::AccountOwner,
        Some(ctx.accounts.new_owner.key()),
    )
}
 
#[derive(Accounts)]
pub struct SetOwnerAccounts<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        mut,
        token::token_program = token_program,
        token::authority = authority
    )]
    pub token_account: InterfaceAccount<'info, token_interface::TokenAccount>,
    /// CHECK: delegat to approve
    #[account(mut)]
    pub new_owner: AccountInfo<'info>,
    pub token_program: Interface<'info, token_interface::TokenInterface>,
}

Open up the /tests/set-owner-example.ts file. There are four tests we'll write for this one. Two for setting the Owner without a CPI and two for setting the owner via CPI.

Notice we've taken out delegate and added firstNonCPIGuardAccount, secondNonCPIGuardAccount, and newOwner.

Starting with the first "stops 'Set Authority' without CPI on a CPI-guarded account" test, we'll create the mint and CPI-guarded token account.

it("stops 'Set Authority' without CPI on a CPI-guarded account", async () => {
  await createMint(
    provider.connection,
    payer,
    provider.wallet.publicKey,
    undefined,
    6,
    testTokenMint,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await createTokenAccountWithCPIGuard(
    provider.connection,
    payer,
    payer,
    userTokenAccount,
    testTokenMint.publicKey,
  );
});

Then, we'll try to send a transaction to the set_authority instruction of the Token Extensions Program with the setAuthority function from the @solana/spl-token library.

// inside the "stops 'Set Authority' without CPI on a CPI-guarded account" test block
try {
  await setAuthority(
    provider.connection,
    payer,
    userTokenAccount.publicKey,
    payer,
    AuthorityType.AccountOwner,
    newOwner.publicKey,
    undefined,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
} catch (e) {
  assert(
    e.message ==
      "failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x2f",
  );
  console.log(
    "Account ownership cannot be changed while CPI Guard is enabled.",
  );
}

This transaction should fail, so we wrap the call in a try/catch block and ensure the error is the expected error.

Next, we'll create another token account without the CPI Guard enabled and attempt the same thing.

it("Set Authority without CPI on Non-CPI Guarded Account", async () => {
  await createAccount(
    provider.connection,
    payer,
    testTokenMint.publicKey,
    payer.publicKey,
    firstNonCPIGuardAccount,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await setAuthority(
    provider.connection,
    payer,
    firstNonCPIGuardAccount.publicKey,
    payer,
    AuthorityType.AccountOwner,
    newOwner.publicKey,
    undefined,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
});

This test should succeed.

Now, let's test this out using a CPI. To do that, we just have to send a transaction to the set_owner instruction of our program.

it("[CPI Guard] Set Authority via CPI on CPI Guarded Account", async () => {
  try {
    await program.methods
      .setOwner()
      .accounts({
        authority: payer.publicKey,
        tokenAccount: userTokenAccount.publicKey,
        newOwner: newOwner.publicKey,
        tokenProgram: TOKEN_2022_PROGRAM_ID,
      })
      .signers([payer])
      .rpc();
  } catch (e) {
    assert(
      e.message ==
        "failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x2e",
    );
    console.log(
      "CPI Guard is enabled, and a program attempted to add or change an authority.",
    );
  }
});

Lastly, we can create another token account without the CPI Guard enabled and pass this to the program instruction. This time, the CPI should go through.

it("Set Authority via CPI on Non-CPI Guarded Account", async () => {
  await createAccount(
    provider.connection,
    payer,
    testTokenMint.publicKey,
    payer.publicKey,
    secondNonCPIGuardAccount,
    undefined,
    TOKEN_2022_PROGRAM_ID,
  );
 
  await program.methods
    .setOwner()
    .accounts({
      authority: payer.publicKey,
      tokenAccount: secondNonCPIGuardAccount.publicKey,
      newOwner: newOwner.publicKey,
      tokenProgram: TOKEN_2022_PROGRAM_ID,
    })
    .signers([payer])
    .rpc();
});

And that is it! You should be able to save your work and run anchor test. All of the tests we have written should pass.

Challenge #

Write some tests for the Transfer functionality.