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.
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.
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 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
}
}
A mix of snake_case and camelCase is used.
test_someFunction_whenCondition()
is used for tests, when no reverts are supposed to happen:someFunction
refers to a function that is being testedwhenCondition
(optional) refers to special condition for a test. A minimal yet explicit name should be used. E.g.noTips
instead ofdoesNotUseAnyTipsWhatsoever
.
test_someFunction_revert_whenCondition
is used for tests, when reverts are supposed to happen:someFunction
refers to a function that is being testedwhenCondition
refers to a condition when the function is supposed to revert. By default, functions are not supposed to revert, so the revert condition should always be mentioned in the function name. See above for picking a minimalistic condition name.
Usual workflow is:
- Set up conditions for the test.
- Trigger the function that is being tested.
- Check any of the following things:
- Events emitted
- State after the function call
- Revert that should have happened
Some of the tests do that a couple of times. Example being executing messages on Destination
:
- Set up conditions:
- Dispatch a few messages from the origin (remote) chain to destination (local) chain.
- Prepare merkle proofs for all these messages.
- Create and sign attestation for the remote chain.
- Submit it to
Destination
contract on local chain. - Wait until optimistic period is over.
- Trigger
Destination.execute()
on local chain, providing a valid merkle proof. - Check things:
- Destination's Event was emitted, as well as the "logging" event was emitted by the message recipient.
- Or, check that call was reverted, if (insert condition)
// 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:
- A collection of messages sharing the same context (user sends a message from
DOMAIN_REMOTE
toDOMAIN_LOCAL
) is created.
- Each dispatched messages is constructed first.
- Its payload and hash is saved.
- It is then dispatched from corresponding
Origin
(onDOMAIN_REMOTE
in that example) - Finally, a collection of merkle proofs is generated.
- 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. - We expect
AttestationAccepted
event to be emitted. - We submit attestation to
Destination
and expect return value oftrue
. - We check the submission time for the attestation root.
// 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:
- We reuse
test_submitAttestation()
to get us to the state where messages are dispatched, and attestation is submitted toDestination
- We execute messages one by one, and check that everything went fine:
- That recipient is passed the correct
(origin, nonce, sender, message)
data - That
Destination
has the sametips
payload which was used for sending a message. - That
Executed
event is emitted. - That message is marked as executed on
Destination
(preventing another execution)
Everything is reusable!
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 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.
A collection of the base contracts used for Foundry tests.
Inherits from SynapseUtilities, SynapseTestStorage.
The base contract to be used in tests. SynapseTestSuite.setUp()
deploys all messaging contracts for three chains:
DOMAIN_SYNAPSE
: to be used as the "master chain" for messaging contracts. Attestations about all chainsOrigin
state are posted here. Also here happens bonding and unbonding of the off-chain actors.DOMAIN_LOCAL
: to be used as the "main testing chain". Tests where messages are sent, will usually feature messages from "local chain" to "remote chain".DOMAIN_REMOTE
: to be used as the "auxiliary testing chain". Tests where messages are received, will usually feature messages from "remote chain" to "local chain".
In the same setUp
functions, a collection of off-chain actors are created. For each actor the private key is saved for later signing.
- Notary. A predetermined amount of notaries is created for each chain.
- Guard. A predetermined amount of guards is created.
- Owner. A single account is created. For every
Ownable
contract, the deployer contract transfers the ownership toowner
. This emulates real world behavior, and helps with testing: calls withoutvm.prank(address)
are made from the testing contract, which is also the deployer, so it makes sense to give away the ownership to prevent false negatives. - Proxy admin. Some of the contracts are supposed to be deployed as upgradeable proxies. Proxy admin address is helpful for:
- Fuzzing, to remove it from the list of callers. Calls from proxy admin are not forwarded, causing the test to fail, when it's not supposed to.
- Testing upgradeability (to be implemented when needed)
- Attacker. A single account is created for various tests of unauthorized access (including signatures of unauthorized agents).
- User. A single account is created to serve as
msg.sender
for legit tests like "sending a message". - Broadcaster. A single account is created to serve as
msg.sender
for tests, where signature of a off-chain actor is submitted on chain.
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.
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.
Features a collection of constants used for the messaging tests.
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.