Composable Verification

Historically, Wormhole has taken a pragmatic approach to bridging — a 2/3+ multi sig, full nodes, and off-chain security features. For many use cases, this nicely packaged solution strikes a balance between flexibility and safety, but for more demanding integrations, additional validation mechanisms are easily composable with Wormhole. In this article, we will explore two add-ons to the typical integration: an additional off-chain signer and a two-of-two bridge approach.

Working examples of the following are available at https://github.com/wormhole-foundation/example-composable-verification

A Basic Integration

The most basic of Wormhole integrations come down to three steps:

1. Send a message

messageSequence = wormhole.publishMessage{ value: wormholeFee }(
0, // batchID
encodedMessage, // payload
200 // consistencyLevel - on Eth: 200 = instant, 201 = safe, 1 = finalized
);

Call publishMessage on the Wormhole Core bridge. Essentially, this emits an event log that the guardians are all watching for.

2. Fetch the VAA

const address = "0x3ee18B2214AFF97000D974cf647E7C347E8fa585" // e.g. Token Bridge
const chainId = 2 // Ethereum
const emitter = address.slice(2).padStart(64, "0"); // 32-byte padded
const sequence = 0 // Get this from the logs or using the sdk
const url = `${GUARDIAN_RPC}/v1/signed_vaa/${chainId}/${emitter}/${sequence}`
const response = await axios.get(url)
const vaa = Buffer.from(response.data.vaaBytes, "base64").toString("hex");

Once a 2/3+ majority of guardians have witnessed and signed the message details, it becomes available as a VAA — basically your payload with some metadata and the guardian signatures.

3. Receive the message

/// Stores the last message received
string public message;
/// Verified message hash to boolean
mapping(bytes32 => bool) public consumedMessages;
/// Used to receive a message
/// @param _vaa The encoded wormhole message (VAA) to receive
function receiveMessage(bytes memory _vaa) public {
// call the Wormhole core contract to parse and verify the encodedMessage
(
IWormhole.VM memory wormholeMessage,
bool valid,
string memory reason
) = wormhole.parseAndVerifyVM(_vaa);
// confirm that the Wormhole core contract verified the message
require(valid, reason);
// verify that this message was emitted by a registered emitter
require(
wormholeMessage.emitterChainId == emitterChainId,
"invalid emitter chain"
);
require(
wormholeMessage.emitterAddress == emitterAddress,
"invalid emitter address"
);
// decode the message payload into your message struct
UpdateMessage memory parsedMessage = decodeMessage(wormholeMessage.payload);
// protect against replay / double-spend
require(
!consumedMessages[wormholeMessage.hash],
"message already consumed"
);
message = parsedMessage.message;
consumedMessages[wormholeMessage.hash] = true;

Pass that VAA to your receiving contract, which should in turn call parseAndVerifyVM which validates the list of signatures, ensures there are enough for quorum, and parses it into a more user friendly struct. Then, perform some checks as applicable to your use case - typically integrators only want to accept messages from other chain + contract pairs they have deployed and do not want to allow double-spends. Check out the the book for more on best practices.

Additional Signers

What if your project had some additional off-chain process to perform after the message was emitted but before the message could be consumed on the receiving chain. Say you have created a fancy NFT reward where the token ids are sequence numbers and you want to dynamically generate the off-chain metadata only after a user has completed the task on the source chain, but you want to ensure that file exists before the token is minted on the receiving side.

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, but this is just an example for a point-to-point integration.

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

What if you have something so critical that you want a safety-deposit box or nuclear launch level of assurance. You are just not comfortable entrusting your most sensitive message to any one bridge. You have heard of the two-man rule and you want the two-bridge rule.

In this example, we will consider sending a message from Ethereum to Optimism and leverage Wormhole and the native bridge, 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 the 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 consumedMessages.

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!

Conclusion

In this post, we have explored two ways to compose additional message verification alongside Wormhole. Check out the accompanying example repo and give these a try yourself!

--

--

Cross-chain interoperability protocol connecting high value blockchains

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Wormhole

Cross-chain interoperability protocol connecting high value blockchains