Assertion Guide
Write assertions and test them with the Credible Layer
Introduction
The Credible Layer is a security prevention framework that helps protect smart contracts by continuously verifying critical protocol invariants.
This guide assumes that you have already installed the Credible Layer. If you haven’t, please see the Credible Layer Quickstart for instructions.
For a visual learners a video introduction to assertions is available:
We will use a simple ownership protocol as an example that demonstrates how to protect against unauthorized ownership transfers - a critical security concern in smart contracts.
In the above code, we have a simple contract that manages ownership transfers. While ownership transfer functionality is common in protocols, it should be carefully controlled since unauthorized changes can be catastrophic. For example, in 2024, Radiant Capital lost $50M when an attacker gained access to their multisig and changed the protocol owner.
To prevent such incidents, we can write an assertion that guards ownership changes. The assertion will block transactions if ownership changes unexpectedly, though it can be temporarily paused when legitimate ownership transfers are planned.
Our assertion will verify that the contract owner remains unchanged after each transaction.
1. Setting Up Your Project
For detailed instructions on setting up your Credible Layer project structure, please refer to the Credible Layer Quickstart Guide.
Alternatively, you can quickly get started by cloning the Credible Layer starter repository:
This repository includes the necessary file structure, dependencies, and configuration for writing and testing assertions.
2. Writing the Assertion
Let’s create our assertion in OwnableAssertion.a.sol
. Here’s the complete code first, followed by a detailed breakdown:
How Assertions Work
The key principle of assertions is simple: if an assertion reverts, it means the assertion failed and an undesired state was detected. In our case, the assertion will revert if the contract’s owner changes, allowing us to prevent unauthorized ownership transfers.
If an assertion reverts, the transaction resulting in the undesired state is reverted and not included in the block. This results in actual hack prevention, not just mitigation.
Anatomy of the Assertion
Let’s break down the key components of our assertion:
1. Imports and Contract Definition
- The contract inherits from
Assertion
, which provides the base functionality and cheatcodes for assertions - We import the contract we want to protect (
Ownable
)
2. State Variables and Constructor
- State variables store the address of contract the assertion is protecting
- The constructor initializes this variable when the assertion is deployed
- The constructor is optional, but it is recommended to use this approach to have more flexibility and be able to use the same assertion to protect different contracts
- You can hardcode the address of the contract you want to protect in the assertion contract, but this only makes sense if you are sure that the assertion will only be used with that one contract and you want to safe a bit of gas
The constructor of the assertion runs against an empty state. That means you can pass values in the constructor but you can not read values from other contracts.
3. Triggering Assertions
- The
triggers
function is required by theAssertion
interface - It defines which functions in your contract are assertions
- Each assertion function must be registered here via its function selector
- Multiple assertions can be defined in a single contract
registerCallTrigger
specifically registers functions to be called after any interaction with the protected contract- Use triggers to make sure that assertions are only called when they are needed in order to save gas and resources
The Credible Layer supports different trigger types. registerCallTrigger
is used to run assertions after any transaction interacting with the protected contract. Other triggers can be implemented depending on your needs.
See the “Triggers” section in the cheatcodes documentation for more information.
4. Assertion Logic
- The main assertion logic uses special features:
ph.forkPreState()
: Examines state before the transactionph.forkPostState()
: Examines state after the transaction
- The assertion reverts if the condition is not met
- The
ph
namespace refers to the Phylax Helper, which provides utility functions for assertions for more information see the cheatcodes documentation - All assertion functions must be public or external to be callable by the Credible Layer
Best Practices for Writing Assertions
- Single Responsibility: Each assertion should verify one specific property or invariant
- State Management: Use
forkPreState()
andforkPostState()
to compare values before and after transactions - Comprehensive Testing: Write thorough tests to verify both positive and negative cases
3. Testing the Assertion
Testing assertions is a critical step in developing effective security prevention. Since assertions act as automated guardians for your protocol, it’s essential to verify they correctly identify both safe and unsafe conditions. A false positive could unnecessarily block legitimate protocol operations, while a false negative could miss critical security violations.
To run the tests, you can use the following command:
Good assertion tests should:
- Verify that assertions fail when they should (e.g., when detecting unauthorized changes)
- Confirm that assertions pass during normal operation
- Simulate realistic protocol interactions
Let’s break down how to test our assertion:
Test Imports
Let’s examine each import:
OwnableAssertion
: This imports the assertion we created that we want to testOwnable
: Imports the protocol contract that our assertion is protectingCredibleTest
: Provides special testing utilities for assertion testing, including thecl
cheatcode interfaceTest
: Standard testing base contract from Forge, providing utilities likevm.prank
andvm.expectRevert
These imports provide all the necessary components to test our assertion effectively, including access to both the assertion logic and the protocol being protected.
Test Contract Structure
- Inherits from
CredibleTest
andTest
which provides testing utilities CredibleTest
provides thecl
cheatcode interface for testing PCL assertionsTest
is the standard Forge test base contractassertionAdopter
is the contract we’re protectingnewOwner
is used to test ownership transfersinitialOwner
is the owner of the contract before the tests are run
Setup
- Creates a new instance of the
Ownable
contract for testing - Gives the owner some ETH using Forge’s
vm.deal
- This setup runs before each test function
Testing Assertion Failure
Key components:
cl.addAssertion()
: Links the assertion to the contract being protected- First argument: Label of the assertion (use the name of the assertion you are testing)
- Second argument: Address of contract to protect
- Third argument: Bytecode of the assertion contract
- Fourth argument: Constructor arguments for the assertion
vm.prank()
: Simulates a call from the owner’s addressvm.expectRevert()
: Expects the assertion to revertcl.validate()
: Tests how the assertion responds to a transaction- First argument: Label of the assertion (use the name of the assertion you are testing)
- Second argument: Address of contract to protect
- Third argument: ETH value (0 in this case)
- Fourth argument: Encoded function call (transferOwnership in this case)
cl
is the cheatcode exposed by the CredibleTest
contract used specifically for testing assertions. It provides tools to validate your assertions under different conditions without having to deploy them to a real blockchain.
Testing Assertion Success
- Similar setup but tests the case where no ownership change occurs
- Uses transferOwnership to the same owner to simulate a transaction that doesn’t change the state in ways that violate our assertion
cl.validate()
will not revert if the assertion passes so we don’t need to usevm.expectRevert()
Full Test Contract
Key Testing Concepts
- Assertion Registration: Use
cl.addAssertion()
to register assertions with contracts - Transaction Simulation: Use
cl.validate()
to test how assertions respond to transactions - State Manipulation: Use Forge’s cheatcodes (
vm.prank
,vm.deal
) to set up test scenarios - Verification: Use
vm.expectRevert()
to verify that the assertion reverts when expected - Labeling: Use the name of the assertion you are testing as the label for improved feedback in logs
Running the Tests
Conclusion
Assertions provide a powerful mechanism for protecting your protocol’s state and preventing security violations in real-time. By implementing well-designed assertions, you can:
- Enhance Security: Block unauthorized changes to critical protocol parameters
- Eliminate Risk: Prevent potential exploits before any damage occurs
- Build Trust: Demonstrate to users that your protocol is actively protected
- Sleep Better: Know that your protocol has an active defense layer
The simple ownership assertion we built in this guide demonstrates just one application. In practice, you might want to secure more complex invariants, such as:
- Total supply of tokens should never increase unexpectedly
- Funds should never be withdrawn without proper authorization
- Key protocol parameters should only change through governance
- Protocol reserves should always be sufficient to cover liabilities
- Transaction values should never exceed the protocol’s limits
- Voting power recorded during snapshot should match the actual voting power
- Deviation of the price of a token from the oracle should never exceed a certain threshold
- And many more…
By following the best practices outlined in this guide, you can create assertions that are both effective and easy to maintain. Remember to test thoroughly, optimize for gas efficiency, and focus on the most critical aspects of your protocol.
What’s Next?
- Explore the assertion examples in the Assertions Book
- Learn about the Cheatcodes
- Follow the PCL Quickstart to learn how to deploy your assertions
- For a comprehensive list of terms and concepts used in PCL, see the Glossary.