If you’ve read our article about blockchain programming languages, you know that the smart contracts on Solana are developed either in C or Rust. Most projects in fact choose Rust, simply because it is safer and easier to use.
Since security is key in blockchain-based programs and the first step towards it on Solana is choosing Rust, what else can you do?
There are several ways to make Solana programs more secure:
- extensive testing
- using existing frameworks (e.g. Anchor)
- requesting security audits
- using type theory
Now, let’s dive deeper into what is involved in each of those.
Launching Solana programs without extensive testing can be reckless as they cannot be trusted just after you write them. Tests come in handy especially to validate if the program is working correctly. They will also be helpful in checking test cases that return errors.
Another thing to be aware of is the test coverage. You should keep the coverage at a high level as it helps you find the tests that are still missing.
But there’s one major problem with testing–you cannot check and test what you’re not aware of. You can find publications about common pitfalls and attack vectors that will guide you to some extent, but they look into the commons. By definition, such publication isn’t a complete guide to all potential errors.
Many teams have tried to develop their own development frameworks, but still the most popular is Anchor. It helps in creating frontend, securing the program, and faster, more in-depth testing.
It partially solves the “I don’t know what I don’t know” problem. Frameworks solve them with magic checks and by documenting the tests well. But why partially? They still leave a chance that the program can be exploited.
The procedures that are the most vulnerable are the parts of code that require some preconditions. It is so common to omit one of the steps while adding some new features. Helper functions may be helpful, but when the program gets more and more complicated, it is hard to track if and where to execute the particular step. Callbacks and dependency injection makes it even worse.
Another problem could be around performance. Frameworks usually do more than needed, because they have to be flexible. Since they need to suit many cases, they might not be the best for your particular project from the performance standpoint.
A security audit company is like a personal trainer. They have the knowledge to solve your specific problem, so you can hire them to guide you. To do so, they’ll build a mathematical model of the program and try to prove it works as intended. Or find out that it’s not and provide you with a proposition of fixes.
A security auditing company can do one thing your developers cannot–look at the code from the outside. This gives a fresh view on the project, however audits also come with several drawbacks, too. They take time and can get quite costly. This is also because when some changes are required, the audit has to be started all over again to ensure that fixes haven’t brought any new errors.
Introducing type theory
For most developers, types are just a mechanism to organize the code and encapsulate access to primitives such as strings or integers. Surprisingly, types can also be used to prove some mathematical properties. Programming languages having first class types like Lean or Idris use upgraded version of types: dependent types. They are not supported in Rust.
The type theory is complex, but there are a few simple rules:
- if the function has a parameter of type T, it can be called only if an instance of type T can be created
- creating the instance of type T is a proof that one of constructors of type T was called
- constructor is a function
Here’s an example:
Looking at the example, we can say a few things:
- Uninitialized record can be created only using function from
- To call from function, caller must provide the proof that record is deserialized
- Function from creates the proof, that record is uninitialized and destroys deserialized record
- Method initialize requires self, which is the proof that record is not initialized
- Uninitialized record is destroyed at the end of function initialize, so it cannot be initialized twice
It might be surprising, that all the variables are passed by value, but:
- protocol code will be efficiently optimized by Rust and helper private functions can still use references
- side effects cannot be emitted twice
- at the end of instruction handler, all the variables are consumed, there is nothing you can use from the stack
Record program rewritten
Record program is a simple smart contract from Solana Program Library. It can be used to store any data in the account. The account data and authority can be modified and the records can be erased or closed as well.
I have rewritten the same program with a few goals in mind:
- program must have similar size
- program must have similar execution time
- all the types must be used and declared in separate modules
- types should be impossible to instantiate from the outside
- create an API with guarantee that if the program works, it means it is safe
To stick to the core of this example, I’ve only rewritten the instruction handling part, so the order of getting account info will not be checked during compilation.
Initialization requires proof of uninitialized record, that is: `record_uninitialized::Type`.
It’s easy to see that introduced functions do too much. It's because of the rules:
- Do not create extra type if it is not necessary
- Store only required data in proof object
You would be surprised how nicely this program evaluates.
Write requires proof of authorized record: record_authorized::Type.
To authorize a record, it must be deserialized first. It is already done in steps a1-4, so it is a good idea to extract record_deserialized::Type out of them.
Another duplicated code is step A1, which can be saved as authority_info::Type.
Set authority function requires proof of authorized record, the same as write instruction.
Someone could say that there’s plenty of duplicated code, but actually, nothing really can go wrong here. All the lines are required by the compiler to call the set_authority method.
Close account function requires proof of authorized record, but also mutable reference to record lamports. The easiest way to achieve it is to store lamports in the authorized record, but it affects other instructions. Let's try to make it optimal.
In order to get mutable reference to record lamports, steps a1-2 must be extracted into record_info::Type. Before creating a deserialized record from record info, lamports must be extracted, and then both passed to ready to close constructor.
The full source code can be found here. Of course, it is not ready to deploy, but nicely describes required steps to achieve this particular effect. There is some duplicated code, but it does not introduce any risk, it may just take some more time to extract a new type if required in the future.
As shown, introducing type-driven development is quite easy in Solana programs written in Rust. What’s also encouraging is the fact that it can be used in any Solana project, with any framework.
And if you’re wondering what else you can verify with types–there are plenty of those, but that’s a story for another post.
If you'd like to build a project on Solana or audit your code, don't hesitate to contact us!