How do cross-chain bridges work? A case on Wormhole (Part 4)
Following Parts 1, 2, and 3, this article focuses on explaining how Wormhole prevents VAA replay attacks (i.e., the same message can never be delivered twice on the target chain).
This is extremely important because otherwise malicious users may steal tokens by redeeming tokens with the same VAA repeatedly many times.
Prevent double-delivery of the same message (VAA replay)
Essentially, each successfully redeemed VAA has to be remembered globally, by maintaining an on-chain flag indicating that the VAA has already been redeemed.
Any transactions that attempt to reuse a VAA marked by the is_already_redeemed flag will be rejected.
We next explain how this is implemented on Solana and Ethereum.
On Solana, the final message delivery functions (complete_native and complete_wrapped) both take an input PDA account claim and use it to prevent VAA double signing:
pub claim: Mut<Claim<'b>>,
When calling, the claim account must not be initialized:
pub type Claim<'a> = Data<'a, ClaimData, { Uninitialized }>;
The consume function is called to initialize and check the validity of the claim account:
claim::consume(ctx, payer: accs.payer.key, &mut accs.claim, message: &accs.vaa)?;
The claim account is a PDA determined by emitter_address, emitter_chain and sequence from the VAA:
/// Consume a claim by initializing the account.
pub fn consume<T>(
ctx: &ExecutionContext,
payer: &Pubkey,
claim: &mut Claim,
message: &PayloadMessage<T>,
) -> Result<()>
where
T: DeserializePayload,
{
// Verify that the claim account is derived correctly before claiming.
claim.verify_derivation(
ctx.program_id,
accs: &ClaimDerivationData {
emitter_address: message.meta().emitter_address,
emitter_chain: message.meta().emitter_chain,
sequence: message.meta().sequence,
The claim account has a flag claimed, which is set to true after initialization:
// Claim the account by initializing it with a value.
claim.create(
accs: &ClaimDerivationData {
emitter_address: message.meta().emitter_address,
emitter_chain: message.meta().emitter_chain,
sequence: message.meta().sequence,
},
ctx,
payer,
lamports: Exempt,
)?;
claim.claimed = true;
With the above, Wormhole ensures that each VAA with a unique combination of (emitter_address, emitter_chain and sequence) creates a unique claim account, which can only be used once successfully.
Similarly on Ethereum, Wormhole maintains a global map completedTransfers that stores all the completed VAA hashes:
mapping(bytes32 => bool) completedTransfers;
The VAA hash is a keccak256 hash on the VAA bytes (excluding the guardian signatures):
/*
SECURITY: Do not change the way the hash of a VM is computed!
Changing it could result into two different hashes for the same observation.
But xDapps rely on the hash of an observation for replay protection.
*/
bytes memory body = encodedVM.slice(index, encodedVM.length - index);
vm.hash = keccak256(abi.encodePacked(keccak256(body)));
This ensures that each VAA hash is unique for the same message, even if the guardians are different.
On every message delivery call, it will check if the VAA hash is already marked (isTransferCompleted) and abort if true, otherwise it will deliver the message and mark the hash (setTransferCompleted):
require(!isTransferCompleted(vm.hash), "transfer already completed");
setTransferCompleted(vm.hash);
function isTransferCompleted(bytes32 hash) public view returns (bool) {
return _state.completedTransfers[hash];
}
function setTransferCompleted(bytes32 hash) internal {
_state.completedTransfers[hash] = true;
}
In the above, _state.completedTransfers is a map on the global storage, and it has to be updated on every successful message transfer:
// Mapping of consumed token transfers
mapping(bytes32 => bool) completedTransfers;
Note that on Ethereum, global storage operations (SSTORE) incur the largest gas cost. This explains why redeeming a token is expensive. For example, in the following transaction, calling completeTransfer costs $6.81 (270K gas):
