Skip to main content
Testing assertions is very similar to writing regular Forge tests. If you’re familiar with Forge testing, you’ll feel right at home. You get access to all the same testing utilities:
  • Standard assertions (assertEq, assertTrue, etc.)
  • Cheatcodes (vm.prank, vm.deal, vm.warp, etc.)
  • Console logging
  • Test setup with setUp() function

Credible Layer Testing Interface

The Credible Layer extends Forge’s testing capabilities with additional utilities specifically for testing assertions. We want to keep the interface minimal and easy to use:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {Vm} from "../lib/forge-std/src/Vm.sol";

interface VmEx is Vm {
    function assertion(address adopter, bytes calldata createData, bytes4 fnSelector) external;
}

contract CredibleTest {
    VmEx public constant cl = VmEx(address(uint160(uint256(keccak256("hevm cheat code")))));
}

Using the Testing Interface

Here’s how to use these utilities in your tests:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import {OwnableAssertion} from "../src/OwnableAssertion.a.sol";
import {Ownable} from "../../src/Ownable.sol";
import {CredibleTest} from "credible-std/CredibleTest.sol";
import {Test} from "forge-std/Test.sol";

contract TestOwnableAssertion is CredibleTest, Test {
    // Contract state variables
    Ownable public assertionAdopter;
    address public initialOwner = address(0xf00);
    address public newOwner = address(0xdeadbeef);

    // Set up the test environment
    function setUp() public {
        assertionAdopter = new Ownable(initialOwner);
        vm.deal(initialOwner, 1 ether);
    }

    // Test case: Ownership changes should trigger the assertion
    function test_assertionOwnershipChanged() public {
        assertEq(assertionAdopter.owner(), initialOwner);

        cl.assertion({
            adopter: address(assertionAdopter), // Address of the contract to protect
            createData: type(OwnableAssertion).creationCode, // Bytecode of the assertion contract
            fnSelector: OwnableAssertion.assertionOwnershipChange.selector // Selector of the assertion function to run
        });

        // Simulate a transaction that changes ownership
        vm.prank(initialOwner);
        vm.expectRevert("Ownership has changed");
        assertionAdopter.transferOwnership(newOwner); // This will trigger the assertion

        // Check that owner didn't change
        assertEq(assertionAdopter.owner(), initialOwner);
    }
}
Testing uses reverts to simulate production behavior where transactions are dropped. See Testing vs. Production for details.
Key concepts:
  • Use cl.assertion() to register assertions that will be run on the next transaction
  • Only one assertion function can be registered at a time, which allows you to test assertion functions in isolation
  • Use vm.prank() to simulate a transaction from a specific address
  • Use vm.expectRevert() to verify that the assertion reverts when expected
  • vm.expectRevert() allows you to verify the exact error message returned by the assertion
  • The next transaction after cl.assertion() will be validated against the assertion
  • State changes caused by a non-reverting transaction will be persisted

Testing Assertion Contracts with Constructor Arguments

Most of the time it’s best practice to use ph.getAssertionAdopter() to get the assertion adopter instead of explicitly setting it in the constructor of the assertion contract. There might however be cases where you want to define additional values that your assertion contract should have access to. For example, this could be the address of a token. In this case you need to append the ABI-encoded constructor arguments to the contract bytecode. The abi.encodePacked() function concatenates the bytecode with the encoded arguments, creating the complete deployment data needed to deploy the contract with the correct constructor parameters.
createData: abi.encodePacked(type(OwnableAssertion).creationCode, abi.encode(address(token)))
This combines:
  • type(OwnableAssertion).creationCode - the contract bytecode
  • abi.encode(address(token)) - the encoded constructor arguments

Testing Patterns

This section shows useful patterns that can be used when testing assertions.

Batch Operation Testing Pattern

This pattern is used to test assertions that need to verify behavior across multiple operations in a single transaction. It involves creating a helper contract that performs multiple operations in a single function, allowing us to test the assertion’s ability to track and validate the cumulative effect of these operations.

Key Components:

  1. A helper contract with a function that performs multiple operations
  2. Testing by calling the helper contract’s function with empty calldata
  3. Verification of the final state after all operations

Example Implementation:

// Helper contract that performs multiple operations
contract BatchOperations {
    TargetContract public target;

    constructor(address target_) {
        target = TargetContract(target_);
    }

    function batchOperations() external {
        // Perform multiple operations in a single transaction
        target.deposit(100);
        target.deposit(200);
        target.deposit(300);
    }
}

// Test function
function testMultipleOperations() public {
    // Regular test setup omitted for brevity
    
    // Create helper contract
    BatchOperations batch = new BatchOperations(address(target));

    // cl.assertion() omitted for brevity

    // Execute all operations in one transaction
    batch.batchOperations();
}
If you use a fallback function, you need to call it with address(batchUpdater).call(""). This is not the recommended way to do batch operations, since it’s a low level call and it doesn’t properly bubble up the revert reason. We recommend using the pattern above instead.If you do use a fallback function, you can use the following pattern to check the result of the batch operation:
(bool success,) = address(batchUpdater).call(""); // Empty calldata triggers fallback
require(!success, "assertion reason");

Benefits:

  • Tests multiple operations in a single transaction
  • Verifies that assertions can track cumulative effects
  • More realistic testing of real-world scenarios
This pattern is used in assertions like the Sum of all positions to test how multiple operations affect intra-transaction state changes. It’s particularly valuable for assertions that need to verify invariants across complex sequences of operations within a single transaction.

Protocol Mocking

When testing assertions, it can be difficult to trigger the conditions that would cause an assertion to fail in a real protocol. This is because protocols are designed to be secure and prevent invalid states from occurring. Protocol mocking provides a solution by creating simplified versions of protocols that can be intentionally put into invalid states. For example, you might create a mock protocol that allows direct manipulation of balances or total supply to test if your assertion correctly catches these invalid states.

Key Components:

  1. Create a mock protocol with simplified logic
  2. Test by intentionally putting the protocol into an invalid state
  3. Verify that the assertion correctly catches the invalid state
You can see examples of mock protocols in the Assertion Examples repository.

Gas Limits

Assertion functions have a gas limit of 300k. This limit exists to ensure assertion validation doesn’t slow down block production. If an assertion exceeds this limit, it reverts with OutOfGas. In production, this causes the transaction to be dropped - even if there’s no actual violation. Since Assertion gas usage can fluctuate with the transaction they are validating, a sophisticated attacker could potentially craft a transaction that intentionally makes the Assertion execution to be over the gas limit. In that case, if we didn’t drop the invalidating transaction, the attacker would be able to use the gas limit to get around the system and forcefully include an invalidating transaction. This makes gas limit issues critical to catch during testing.

The Happy Path Problem

Counterintuitively, the happy path is usually the most expensive. When no violation is detected:
  • All checks run to completion
  • Loops iterate through all items
  • No early returns short-circuit execution
Failure cases often exit early when a violation is found, using less gas.

Testing Recommendations

  • Test with realistic data volumes - if your assertion loops through items, test with the maximum expected count
  • Test batch operations at maximum expected size
  • Use pcl test -vvv to monitor gas usage
  • If approaching the limit, optimize or split the assertion

What’s Next?