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);
    }
}
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.