Following Parts 1 2 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.
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 an 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:
The claim account is a PDA determined by emitter_address, emitter_chainand sequence from the VAA:
The claim account has a flag claimed, which is set to true after initialization (line 87):
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):
The 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 line 497) and abort if true, otherwise it will deliver the message and mark the hash (setTransferCompleted line 498).
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):