sanguine

Testing suite for the messaging contracts

TODO: This is an outdated README for a previous version of testing suite. Update this.

The suite contains a series of reusable tools. They are designed to be as composable as the tested contracts.

The testing suite is powered by Foundry.

Directory structure

test
├──harnesses A collection of contracts, which are exposing the messaging contracts variables and functions for testing both in this suite and in the Go tests.
├──suite The Foundry test files for the messaging contracts.
├──tools Testing tools for the messaging contracts, that are used in suite.
├──utils Base test utilities to be used in suite.
│   ├──proof Merkle proof generation.

The directory structure for the messaging contracts is mirrored in harnesses, suite and tools.

Harnesses

For the majority of production contracts, there exists a corresponding harness. It exposes internal constants, functions and variables for testing. It also emits “logging” events to be used for testing. For instance:

// From BasicClientHarness.t.sol
function _handleUnsafe(
  uint32 origin,
  uint32 nonce,
  uint256 rootSubmittedAt,
  bytes memory message
) internal override {
  emit LogBasicClientMessage(origin, nonce, rootSubmittedAt, message);
}

Suite

Suite features a collection of testing contracts. For every production contract there is a corresponding testing one.

The underlying testing logic is usually implemented in the corresponding Tools contract. The tests itself are implemented in the testing contract.

// From BasicClient.t.sol
contract BasicClientTest is BasicClientTools {
  function setUp() public override {
    super.setUp();
    setupBasicClients();
  }

  function test_setup_constructor() public {
    // code for testing state after setUp and constructor
  }

  function test_sendMessage_noTips() public {
    // code for testing "send a message with no tips"
    // should succeed
  }

  function test_sendMessage_withTips() public {
    // code for testing "send a message with tips"
    // should succeed
  }

  function test_sendMessage_revert_noRecipient() public {
    // code for testing "send a message without recipient"
    // should revert
  }
}

Test function naming convention

A mix of snake_case and camelCase is used.

Test function workflow

Usual workflow is:

Some of the tests do that a couple of times. Example being executing messages on Destination:

Example of test function: submitAttestation()

// From Destination.t.sol
function test_submitAttestation() public {
  // Create messages sent from remote domain and prepare attestation
  createMessages({
    context: userRemoteToLocal,
    recipient: address(suiteApp(DOMAIN_LOCAL))
  });
  createSuggestedAttestation(DOMAIN_REMOTE);
  expectAttestationAccepted();
  // Should emit corresponding event and mark root submission time
  destinationSubmitAttestation({ domain: DOMAIN_LOCAL, returnValue: true });
  assertEq(
    destinationSubmittedAt(DOMAIN_LOCAL),
    block.timestamp,
    '!rootSubmittedAt'
  );
}

// From DestinationTools.t.sol
// Creates test messages and prepares their merkle proofs for future execution
function createMessages(MessageContext memory context, address recipient)
  public
{
  bytes32 recipientBytes32 = addressToBytes32(recipient);
  rawMessages = new bytes[](MESSAGES);
  messageHashes = new bytes32[](MESSAGES);
  for (uint32 index = 0; index < MESSAGES; ++index) {
    // Construct a dispatched message
    createDispatchedMessage({
      context: context,
      mockTips: true,
      body: MOCK_BODY,
      recipient: recipientBytes32,
      optimisticSeconds: APP_OPTIMISTIC_SECONDS
    });
    // Save raw message and its hash for later use
    rawMessages[index] = messageRaw;
    messageHashes[index] = keccak256(messageRaw);
    // Dispatch message on remote Origin
    originDispatch();
  }
  // Create merkle proofs for dispatched messages
  proofGen.createTree(messageHashes);
}

Following steps are taken:

  1. A collection of messages sharing the same context (user sends a message from DOMAIN_REMOTE to DOMAIN_LOCAL) is created.
  1. A suggested attestation (i.e. referencing the latest state) is created for DOMAIN_REMOTE. Attestation’s root could be later used for executing the dispatched messages.
  2. We expect AttestationAccepted event to be emitted.
  3. We submit attestation to Destination and expect return value of true.
  4. We check the submission time for the attestation root.

Example of test function: execute()

// From Destination.t.sol
function test_execute() public {
  AppHarness app = suiteApp(DOMAIN_LOCAL);
  test_submitAttestation();
  skip(APP_OPTIMISTIC_SECONDS);
  // Should be able to execute all messages once optimistic period is over
  for (uint32 i = 0; i < MESSAGES; ++i) {
    checkMessageExecution({ context: userRemoteToLocal, app: app, index: i });
  }
}

// From DestinationTools.t.sol
// Prepare app to receive a message from Destination
function prepareApp(
  MessageContext memory context,
  AppHarness app,
  uint32 nonce
) public {
  // App will revert if any of values passed over by Destination will differ (see AppHarness)
  app.prepare({
    origin: context.origin,
    nonce: nonce,
    sender: addressToBytes32(context.sender),
    message: _createMockBody(context.origin, context.destination, nonce)
  });
}

// Check given message execution
function checkMessageExecution(
  MessageContext memory context,
  AppHarness app,
  uint32 index
) public {
  uint32 nonce = index + 1;
  // Save mock data in app to check against data passed by Destination
  prepareApp(context, app, nonce);
  // Recreate tips used for that message
  createMockTips(nonce);
  expectLogTips();
  expectExecuted({ domain: context.origin, index: index });
  // Trigger Destination.execute() on destination chain
  destinationExecute({ domain: context.destination, index: index });
  // Check executed message status
  assertEq(
    destinationMessageStatus(context, index),
    attestationRoot,
    '!messageStatus'
  );
}

Following steps are taken:

  1. We reuse test_submitAttestation() to get us to the state where messages are dispatched, and attestation is submitted to Destination
  2. We execute messages one by one, and check that everything went fine:

Everything is reusable!

Tools

For every tested contract, a corresponding <...>Tools contract is used for basic testing logic. In this contract, testing data is created and saved for later verification. The Tools contract reuse functions from one another to make the testing easier.

abstract contract DestinationTools is OriginTools {
  // Here we define constants and state variables used for testing
  bytes[] internal rawMessages;

  // ...

  // Here we define functions to create test data

  // Creates test messages and prepares their merkle proofs for future execution
  function createMessages(MessageContext memory context, address recipient)
    public
  {
    // ...
  }

  /*╔══════════════════════════════════════════════════════════════════════╗*\
  ▏*║                            EXPECT EVENTS                             ║*▕
  \*╚══════════════════════════════════════════════════════════════════════╝*/

  // Here we define wrappers for expecting a given event

  function expectAttestationAccepted() public {
    vm.expectEmit(true, true, true, true);
    emit AttestationAccepted(
      attestationDomain,
      attestationNonce,
      attestationRoot,
      signatureNotary
    );
  }

  // ...

  /*╔══════════════════════════════════════════════════════════════════════╗*\
  ▏*║                     TRIGGER FUNCTIONS (REVERTS)                      ║*▕
  \*╚══════════════════════════════════════════════════════════════════════╝*/

  // Here we define wrappers for triggering a Destination function and expecting a revert

  // Trigger destination.submitAttestation() with saved data and expect a revert
  function destinationSubmitAttestation(
    uint32 domain,
    bytes memory revertMessage
  ) public {
    DestinationHarness destination = suiteDestination(domain);
    vm.expectRevert(revertMessage);
    vm.prank(broadcaster);
    destination.submitAttestation(attestationRaw);
  }

  // ...

  /*╔══════════════════════════════════════════════════════════════════════╗*\
  ▏*║                          TRIGGER FUNCTIONS                           ║*▕
  \*╚══════════════════════════════════════════════════════════════════════╝*/

  // Here we define wrappers for triggering a Destination function and expecting a given return value

  // Trigger destination.submitAttestation() with saved data and check the return value
  function destinationSubmitAttestation(uint32 domain, bool returnValue)
    public
  {
    DestinationHarness destination = suiteDestination(domain);
    vm.prank(broadcaster);
    assertEq(
      destination.submitAttestation(attestationRaw),
      returnValue,
      '!returnValue'
    );
    if (returnValue) {
      rootSubmittedAt = block.timestamp;
    }
  }
}

Test data

Test data needs to be unique for every test, while also being easily reconstructible for checking. Data is usually constructed from the given parameters, the remaining ones are mocked. Functions for data construction are designed to be composable:

abstract contract OriginTools is MessageTools {
  // Create a dispatched message: given {context, body, recipient, optimistic period}
  // pass MOCK_X constant to mock field X instead
  function createDispatchedMessage(
    MessageContext memory context,
    bool mockTips,
    bytes memory body,
    bytes32 recipient,
    uint32 optimisticSeconds
  ) public {
    createMessage({
      origin: context.origin,
      sender: _getSender(context, recipient),
      nonce: _nextOriginNonce(context.origin),
      destination: context.destination,
      mockTips: mockTips,
      body: body,
      recipient: recipient,
      optimisticSeconds: optimisticSeconds
    });
  }
}

Here OriginTools implements a function to construct a payload for a dispatched message. It reuses the generic createMessage() from MessageTools instead of manually encoding the payload from scratch.

Utils

A collection of the base contracts used for Foundry tests.

SynapseTestSuite

Inherits from SynapseUtilities, SynapseTestStorage.

The base contract to be used in tests. SynapseTestSuite.setUp() deploys all messaging contracts for three chains:

In the same setUp functions, a collection of off-chain actors are created. For each actor the private key is saved for later signing.

SynapseUtilities

Inherits from Test, a default Foundry testing contract.

Features some useful utilities that don’t require access to state variables, like typecasts, key generation, string formatting.

SynapseTestStorage

Inherits from SynapseConstants, SynapseEvents.

Features all storage variables used for testing (like saved deployments, actors, etc), as well as a collection of handy getters for them. Also features a tool to generate Merkle proofs, and the preset context variables for the messaging tests.

SynapseConstants

Features a collection of constants used for the messaging tests.

SynapseEvents

Features all events from the production contracts, as well all the “logging” events from all the harnesses. This way all events are accessable in the testing contracts without the need to redefine them.