Account data
The hardest attack vector to secure is an instruction's accounts. The first step in securing this attack vector is to follow our account checks recommendations detailed in the previous section. The second and hardest step is to look at account data itself.
Broadly speaking, your program will interact with two kinds of accounts:
- Accounts which it owns (and are therefore part of its own state).
- Accounts owned by other programs.
Thinking about your program's state
To secure the first type of accounts, a simple but very effective strategy is to use account tags.
In the Bonfida style, the first 8 bytes of any account are reserved as a descriptor of what kind of account we're dealing with.
If your program's logic allows for the closing of accounts, a closed account's tag should be set to a Disabled
value.
This makes sure that those accounts can't be used as an attack vector.
Outside of this notion of account tagging, the safety of an account owned by your own program depends on the safety of your entire business logic. This is due to the fact that a program-owned account's data is either just zeros or the result of your program's previous interactions with it. This means that whenever your program modifies an account's data (or its state in general), you should make sure that the state remains safe in all situations. To deal with these kinds of vulnerabilities, a clear coding style and proper reviews and even audits are your friends.
Finally, we have accounts owned by other programs. More often than not, depending on an other program's accounts means depending on this program's own safety. Always think twice before integrating new on-chain dependencies into your projects:
- Is the program well reviewed and audited?
- Is the program widely used?
- If the program is upgradable, can the team behind it be trusted?
- Do you have a good understanding of the program and its safety guarantees?
In the case of our token-vesting
contract, we depend on the spl-token
program.
Since this program is part of the official Solana Program Library, and has been around for a while now, we can assume that it's quite safe.
This ticks off the first three requirements.
However, the final point stands: we need to gain an understanding of this program's security guarantees before we can use it in good conscience.
As it just so happens, our program is currently vulnerable because we don't look at what's inside the vault
spl-token account.
Understanding your on-chain dependencies: spl-token
Since on-chain programs are security-critical applications, it is essential to take the time to read through the official documentation.
It often contains security recommendations and can provide you with a better idea of what you are dealing with.
In the case of spl-token
, the documentation can be found here.
Since we are going to be using the smart contract's bindings, we should take a look at the library documentation as well.
It can be found here.
When using an on-chain dependency, we should gain a deep understanding of the interface we are going to be using. A program's interface has two components :
- Its instruction specification (what is encoded in
instruction_data
) - Its state, and how it is encoded into accounts
Instruction specification
We're using spl-token
's transfer
instruction.
Let's take a look at the associated binding's documentation here.
This is what it looks like as of writing this :
/// Creates a Transfer instruction.
pub fn transfer(
token_program_id: &Pubkey,
source_pubkey: &Pubkey,
destination_pubkey: &Pubkey,
authority_pubkey: &Pubkey,
signer_pubkeys: &[&Pubkey],
amount: u64
) -> Result<Instruction, ProgramError>
Ah, the documentation is quite lackluster. Looking at the binding's signature already gives us a bit of information. However, we want to know as much as possible about this instruction. We have several options here :
- Find some documentation elsewhere.
- Read the instruction code.
As a general rule of thumb, there's a pretty straightforward way to find the documentation we need elsewhere. For some reason, program developers are more often than not given less documentation than frontend ones. Maybe it's because program development is supposed to be harder? Really I don't know, and I would encourage any program developer to always think about both web clients and other programs as interfaces to tailor for. I sincerely hope you're reading this and finding this paragraph outdated. In the meantime, we can get the information we deserve by looking at the JavaScript/TypeScript documentation here. This is what it roughly looks like:
Transfer tokens from one account to anotherParameters
connection: Connection
Connection to usepayer: Signer
Payer of the transaction feessource: PublicKey
Source accountdestination: PublicKey
Destination accountowner: PublicKey | Signer
Owner of the source accountamount: number | bigint
Number of tokens to transfermultiSigners: Signer[] = []
Signing accounts if owner is a multisigOptional confirmOptions: Signer[] = []
Options for confirming the transactionprogramId: PublicKey = TOKEN_PROGRAM_ID
SPL Token program accountReturns Promise<TransactionSignature>
Signature of the confirmed transaction
We get more information here.
Let's not care about any parameter mentioned here which isn't part of our Rust binding's signature.
This eliminates connection
and confirmOptions
from consideration.
In general, you should make sure that you understand every option, because it's the only way to determine what's important.
Using this knowledge, we can annotate the Rust binding for our particular use-case: transferring funds to a vault:
/// Creates a Transfer instruction.
pub fn transfer(
/// This will be spl_token::ID since we're using the common spl_token instance
token_program_id: &Pubkey,
/// This will be the key of the provided account currently holding the tokens to vest
source_pubkey: &Pubkey,
/// Our vesting contract's vault key
destination_pubkey: &Pubkey,
/// The owner of the source token account's key
authority_pubkey: &Pubkey,
/// We won't support vesting tokens from a multisig source account.
/// This will be an empty array.
signer_pubkeys: &[&Pubkey],
/// The total quantity of tokens to vest
amount: u64
) -> Result<Instruction, ProgramError>
We're quite lucky here: the existing call to transfer
is correct in our create
instruction!
Account specification
Our create
instruction takes several spl_token
-owned program accounts as input.
We should therefore understand what kind of accounts we should expect here.
Using the library documentation, we find that spl_token
's accounts are all defined in a state
module here.
Out of all the different types of state defined by spl_token
, we're only interested in the Account
type.
This is because we're only using program accounts which hold tokens.
Let's take a look at the definition of spl_token::state::Account
:
pub struct Account {
/// The mint associated with this account
pub mint: Pubkey,
/// The owner of this account.
pub owner: Pubkey,
/// The amount of tokens this account holds.
pub amount: u64,
/// If `delegate` is `Some` then `delegated_amount` represents
/// the amount authorized by the delegate
pub delegate: COption<Pubkey>,
/// The account's state
pub state: AccountState,
/// If is_native.is_some, this is a native token, and the value logs the rent-exempt reserve. An
/// Account is required to be rent-exempt, so the value is used by the Processor to ensure that
/// wrapped SOL accounts do not drop below this threshold.
pub is_native: COption<u64>,
/// The amount delegated
pub delegated_amount: u64,
/// Optional authority to close the account.
pub close_authority: COption<Pubkey>,
}
Here we find that every field is properly documented, which will definitely make our job easier! The next step is to go through each field and think about constraints. How can we use each field to constrain our input accounts as much as possible? I have annotated the same struct definition to see what kind of constraints we could enforce.
pub struct Account {
/// The mint associated with this account
// We could check that our vault and destination mints match
// However, we know that the spl_token program itself will perform this check
pub mint: Pubkey,
/// The owner of this account.
// Our vault should be owned by an account controlled by our program!
// Otherwise the transfer we perform to our vault creates a fake contract
// since our program cannot control the vault's funds!
pub owner: Pubkey,
/// The amount of tokens this account holds.
// We expect our vault to be empty, so let's make sure.
pub amount: u64,
/// If `delegate` is `Some` then `delegated_amount` represents
/// the amount authorized by the delegate
// Our vault should not have a delegate authority.
pub delegate: COption<Pubkey>,
/// The account's state
// The account should be Initialized
pub state: AccountState,
/// If is_native.is_some, this is a native token, and the value logs the rent-exempt reserve. An
/// Account is required to be rent-exempt, so the value is used by the Processor to ensure that
/// wrapped SOL accounts do not drop below this threshold.
// Not caring about this makes our token vesting contract support wrapped SOL.
pub is_native: COption<u64>,
/// The amount delegated
// This should be 0, but we're already enforcing the delegate field to be None
pub delegated_amount: u64,
/// Optional authority to close the account.
// This should absolutely be None for our vault.
// It should be impossible for a third-party to close our vault account.
pub close_authority: COption<Pubkey>,
}
Looking at the Account
struct, we were able to gain a clearer picture of what we should expect from our vault
account.
Note that we're mostly focusing on enforcing constraints on the vault account here.
The other token account at play in our create
instruction only has to emit the tokens.
We can trust this simple constraint to be enforced by spl_token
itself when we call the transfer
instruction.
However, our vault account will remain tied to our vesting contract instance for the entirety of its lifetime. We must therefore make sure that we're getting precisely what we need here. Our vault account can be thought of as a piece of third-party state for our program. This is why we need to understand it as well as we understand our program's own state.