PCL Assertion Guide
Write assertions and test them with the PCL
Introduction
This guide assumes that you have already installed the PCL. If you haven’t, please see the PCL Quickstart for instructions.
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 monitors ownership changes. The assertion will fail 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.
Project Structure
When using PCL assertions, your project should follow this structure:
The phorge
command takes the assertions
directory location as an argument and should be run from the project root containing both assertions
and src
directories.
Writing the Assertion
Let’s create our assertion in OwnableAssertion.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 catch unauthorized ownership transfers.
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 for assertions - We import the contract we want to monitor (
Ownable
)
2. State Variables and Constructor
- State variables store the addresses of contracts we need to interact with
- The constructor initializes these variables when the assertion is deployed
3. Function Selectors
- This required function 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
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
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
Testing the Assertion
Testing assertions is a critical step in developing effective security monitoring. Since assertions act as automated watchdogs for your protocol, it’s essential to verify they correctly identify both safe and unsafe conditions. A false positive could unnecessarily halt 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 Contract Structure
- Inherits from
CredibleTest
which provides testing utilities assertionAdopter
is the contract we’re monitoringnewOwner
is used to test ownership transfers
Setup
- Creates a new instance of the
Ownable
contract for testing - Gives the owner some ETH using Forge’s
vm.deal
Testing Assertion Failure
Key components:
cl.addAssertion()
: Links the assertion to the contract being monitored- First argument: Address of contract to monitor
- Second argument: Bytecode of the assertion contract
- Third argument: Constructor arguments for the assertion
vm.prank()
: Simulates a call from the owner’s addresscl.validate()
: Tests how the assertion responds to a transaction- First argument: Contract address
- Second argument: ETH value (0 in this case)
- Third argument: Encoded function call (transferOwnership in this case)
assertFalse(result)
: Verifies the assertion fails when ownership changes
Testing Assertion Success
- Similar setup but tests the case where no ownership change occurs
- Uses empty bytes (
new bytes(0)
) to simulate no transaction assertTrue(result)
: Verifies the assertion passes when ownership remains unchanged
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 standard assertions (
assertTrue
,assertFalse
) to verify outcomes