xdapp-book/src/technical/evm/composableVerification.md

5.2 KiB

Composable Verification

Wormhole offers flexible Consistency Levels, but more advanced integrators may want additional layers of verification.

Working examples of the following are available in this example repo.

Additional Signers

If your project has some additional off-chain processes or checks to perform after a message was emitted but before it could be consumed on the receiving chain, you may want to consider adding an additional signer requirement to your integration. There are plenty of ways to achieve this concept, but an approach like the following keeps the emission and verification in your integrating contract, entirely separate from Wormhole.

1. Emit a unique message hash

event LogMessageHash(bytes32 hash);
...
bytes32 messageHash = keccak256(
  abi.encodePacked(encodedMessage, messageSequence)
);
emit LogMessageHash(messageHash);

After calling publishMessage, emit a hash for the message and sequence number. This way, the signature will be unique for two different instances of the same message contents. You could also make it more unique across implementations by including the sending chain id and contract address.

2. Sign the hash

function getSigningHash(bytes32 _messageHash) public view returns (bytes32) {
  return
    keccak256(abi.encodePacked(_messageHash, block.chainid, address(this)));
}

It is helpful to have a utility function on the receiving side so you can generate an even more unique hash which ensures the signature you generate is intended for this receiving chain and contract address.

const {
  args: { hash },
} = sender.interface.parseLog(log);
const signingHash = await receiver.getSigningHash(hash);
const additionalSignature = await signer.signMessage(
  ethers.utils.arrayify(signingHash)
);

Have your off-chain process pick up logs via your preferred method (like finalized block polling for eth_getLogs), perform its duties, and produce a signature.

3. Verify the signature

function receiveMessage(
  bytes memory _vaa,
  bytes memory _additionalSignature
) public {
  ...
  require(
    verify(
      keccak256(
        abi.encodePacked(wormholeMessage.payload, wormholeMessage.sequence)
      ),
      _additionalSignature
    ),
    "invalid additional signature"
  );
  ...
}

function verify(
  bytes32 _messageHash,
  bytes memory _signature
) public view returns (bool) {
  bytes32 signingHash = getSigningHash(_messageHash);
  bytes32 ethSignedMessageHash = getEthSignedMessageHash(signingHash);
  return recoverSigner(ethSignedMessageHash, _signature) == signerAddress;
}

Add another parameter to your receiveMessage function and after calling parseAndVerifyVM, verify that the additional signature checks out!

Two-Bridge Rule

This example sends a message from Ethereum to Optimism via Wormhole and sends the hash of that message via the native bridge. The receiver then requires both messages to agree, like requiring two keys to open a safe.

1. Send a unique hash natively

/// Optimism L1-L2 bridge from https://community.optimism.io/docs/useful-tools/networks/#optimism-goerli
address public crossDomainMessengerAddr =
  0x5086d1eEF304eb5284A0f6720f79403b4e9bE294;
/// Optimism bridge requires a recipient address so the message can be relayed
address public receiverL2Addr;
...
// Send the expected message hash and sequence via the native bridge
bytes32 messageHash = keccak256(
  abi.encodePacked(encodedMessage, messageSequence)
);
ICrossDomainMessenger(crossDomainMessengerAddr).sendMessage(
  receiverL2Addr,
  abi.encodeWithSignature("expectPayload(bytes32)", messageHash),
  1000000 // within the free gas limit amount
);

Similar to the previous example, after calling publishMessage, send a hash for the message and sequence number over the native bridge.

2. Receive the expected hash

/// Sender contract address for confirming validity of native bridge messages
address public immutable l1SenderAddress;
/// Stores the expected payload hash
bytes32 public expectedPayloadHash;
/// Used by the native bridge to set the expected payload hash
/// This signature must match the ICrossDomainMessenger.sendMessage call in the Sender
/// @param _expectedPayloadHash The hash of the expected payload for the corresponding Wormhole message
function expectPayload(bytes32 _expectedPayloadHash) public {
  require(getXorig() == l1SenderAddress, "invalid sender");
  expectedPayloadHash = _expectedPayloadHash;
}

Again similar to a basic Wormhole integration where you verify the emitter, verify that this message came from the expected L1 contract. This example only “expects” one message at a time, but you could just as easily make this a map like processedMessages.

3. Verify the hashes match

require(
  keccak256(
    abi.encodePacked(wormholeMessage.payload, wormholeMessage.sequence)
  ) == expectedPayloadHash,
  "unexpected payload"
);

After calling parseAndVerifyVM, verify that the hash checks out!